CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 3m59s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m2s
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
460 lines
20 KiB
Rust
460 lines
20 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::*;
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::components::code_inspector::CodeInspector;
|
|
use crate::components::file_tree::{build_file_tree, FileTree};
|
|
use crate::components::page_header::PageHeader;
|
|
use crate::components::toast::{ToastType, Toasts};
|
|
use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_build};
|
|
|
|
#[component]
|
|
pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|
rsx! {
|
|
div { class: "back-nav",
|
|
button {
|
|
class: "btn btn-ghost btn-back",
|
|
onclick: move |_| { navigator().go_back(); },
|
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
|
"Back"
|
|
}
|
|
}
|
|
|
|
PageHeader {
|
|
title: "Code Knowledge Graph",
|
|
description: "Interactive visualization of code structure and relationships",
|
|
}
|
|
|
|
GraphExplorerBody { repo_id: repo_id }
|
|
}
|
|
}
|
|
|
|
/// Inline variant without back button and page header — for embedding in other pages.
|
|
#[component]
|
|
pub fn GraphExplorerInline(repo_id: String) -> Element {
|
|
rsx! {
|
|
GraphExplorerBody { repo_id: repo_id }
|
|
}
|
|
}
|
|
|
|
/// Shared graph explorer body used by both the full page and inline variants.
|
|
#[component]
|
|
fn GraphExplorerBody(repo_id: String) -> Element {
|
|
let repo_id_clone = repo_id.clone();
|
|
let mut graph_data = use_resource(move || {
|
|
let rid = repo_id_clone.clone();
|
|
async move {
|
|
if rid.is_empty() {
|
|
return None;
|
|
}
|
|
fetch_graph(rid).await.ok()
|
|
}
|
|
});
|
|
|
|
let mut building = use_signal(|| false);
|
|
let mut toasts = use_context::<Toasts>();
|
|
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
|
let mut inspector_open = use_signal(|| false);
|
|
let mut search_query = use_signal(String::new);
|
|
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
|
let mut file_filter = use_signal(String::new);
|
|
let mut nodes_json = use_signal(String::new);
|
|
let mut edges_json = use_signal(String::new);
|
|
let mut graph_ready = use_signal(|| false);
|
|
|
|
let graph_data_read = graph_data.read();
|
|
if let Some(Some(data)) = &*graph_data_read {
|
|
if !data.data.nodes.is_empty() && !graph_ready() {
|
|
let nj = serde_json::to_string(&data.data.nodes).unwrap_or_else(|_| "[]".into());
|
|
let ej = serde_json::to_string(&data.data.edges).unwrap_or_else(|_| "[]".into());
|
|
nodes_json.set(nj);
|
|
edges_json.set(ej);
|
|
graph_ready.set(true);
|
|
}
|
|
}
|
|
|
|
let (node_count, edge_count, community_count, languages, file_tree_data) =
|
|
if let Some(Some(data)) = &*graph_data_read {
|
|
let build = data.data.build.clone().unwrap_or_default();
|
|
let nc = build
|
|
.get("node_count")
|
|
.and_then(|n| n.as_u64())
|
|
.unwrap_or(0);
|
|
let ec = build
|
|
.get("edge_count")
|
|
.and_then(|n| n.as_u64())
|
|
.unwrap_or(0);
|
|
let cc = build
|
|
.get("community_count")
|
|
.and_then(|n| n.as_u64())
|
|
.unwrap_or(0);
|
|
let langs: Vec<String> = build
|
|
.get("languages_parsed")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_str().map(String::from))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
let tree = build_file_tree(&data.data.nodes);
|
|
(nc, ec, cc, langs, tree)
|
|
} else {
|
|
(0, 0, 0, Vec::new(), Vec::new())
|
|
};
|
|
|
|
let has_graph_data = matches!(&*graph_data_read, Some(Some(d)) if !d.data.nodes.is_empty());
|
|
drop(graph_data_read);
|
|
|
|
use_effect(move || {
|
|
let ready = graph_ready();
|
|
if !ready {
|
|
return;
|
|
}
|
|
let nj = nodes_json();
|
|
let ej = edges_json();
|
|
if nj.is_empty() {
|
|
return;
|
|
}
|
|
spawn(async move {
|
|
let js = format!(
|
|
r#"
|
|
window.__onNodeClick = function(nodeJson) {{
|
|
var el = document.getElementById('graph-node-click-data');
|
|
if (el) {{
|
|
el.value = nodeJson;
|
|
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
}}
|
|
}};
|
|
setTimeout(function() {{
|
|
if (window.__loadGraph) {{
|
|
window.__loadGraph({nj}, {ej});
|
|
}}
|
|
}}, 300);
|
|
"#
|
|
);
|
|
let _ = document::eval(&js);
|
|
});
|
|
});
|
|
|
|
let sel = selected_node();
|
|
let sel_file = sel
|
|
.as_ref()
|
|
.and_then(|n| n.get("file_path").and_then(|v| v.as_str()))
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let sel_name = sel
|
|
.as_ref()
|
|
.and_then(|n| n.get("name").and_then(|v| v.as_str()))
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let sel_kind = sel
|
|
.as_ref()
|
|
.and_then(|n| n.get("kind").and_then(|v| v.as_str()))
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let sel_start = sel
|
|
.as_ref()
|
|
.and_then(|n| n.get("start_line").and_then(|v| v.as_u64()))
|
|
.unwrap_or(0) as u32;
|
|
let sel_end = sel
|
|
.as_ref()
|
|
.and_then(|n| n.get("end_line").and_then(|v| v.as_u64()))
|
|
.unwrap_or(0) as u32;
|
|
|
|
rsx! {
|
|
if repo_id.is_empty() {
|
|
div { class: "card",
|
|
p { "Select a repository to view its code graph." }
|
|
}
|
|
} else {
|
|
// Toolbar
|
|
div { class: "graph-toolbar",
|
|
button {
|
|
class: "btn btn-primary",
|
|
disabled: building(),
|
|
onclick: {
|
|
let rid = repo_id.clone();
|
|
move |_| {
|
|
let rid = rid.clone();
|
|
building.set(true);
|
|
graph_ready.set(false);
|
|
nodes_json.set(String::new());
|
|
edges_json.set(String::new());
|
|
spawn(async move {
|
|
match trigger_graph_build(rid).await {
|
|
Ok(_) => toasts.push(ToastType::Success, "Graph build triggered"),
|
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
|
}
|
|
building.set(false);
|
|
graph_data.restart();
|
|
});
|
|
}
|
|
},
|
|
if building() { "Building..." } else { "Build Graph" }
|
|
}
|
|
|
|
div { class: "graph-search-bar",
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "Search nodes...",
|
|
value: "{search_query}",
|
|
oninput: {
|
|
let rid = repo_id.clone();
|
|
move |e: FormEvent| {
|
|
let val = e.value();
|
|
search_query.set(val.clone());
|
|
if val.len() >= 2 {
|
|
let rid = rid.clone();
|
|
spawn(async move {
|
|
if let Ok(resp) = search_nodes(rid, val).await {
|
|
search_results.set(resp.data);
|
|
}
|
|
});
|
|
} else {
|
|
search_results.set(Vec::new());
|
|
}
|
|
}
|
|
},
|
|
onkeypress: {
|
|
let rid = repo_id.clone();
|
|
move |e: KeyboardEvent| {
|
|
if e.key() == Key::Enter {
|
|
search_results.set(Vec::new());
|
|
let q = search_query();
|
|
let rid = rid.clone();
|
|
if !q.is_empty() {
|
|
spawn(async move {
|
|
match search_nodes(rid, q).await {
|
|
Ok(resp) => {
|
|
let names: Vec<String> = resp.data.iter()
|
|
.filter_map(|n| n.get("qualified_name").and_then(|v| v.as_str()).map(String::from))
|
|
.collect();
|
|
let names_json = serde_json::to_string(&names).unwrap_or_else(|_| "[]".into());
|
|
let js = format!("if (window.__highlightNodes) {{ window.__highlightNodes({names_json}); }}");
|
|
let _ = document::eval(&js);
|
|
},
|
|
Err(e) => {
|
|
toasts.push(ToastType::Error, e.to_string());
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
spawn(async move {
|
|
let _ = document::eval("if (window.__clearGraphSelection) { window.__clearGraphSelection(); }");
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
}
|
|
if !search_results().is_empty() {
|
|
div { class: "search-suggestions",
|
|
for node in search_results() {
|
|
{
|
|
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let kind = node.get("kind").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let file_path = node.get("file_path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let node_clone = node.clone();
|
|
let qname = node.get("qualified_name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
rsx! {
|
|
div {
|
|
class: "search-suggestion-item",
|
|
onclick: move |_| {
|
|
let nc = node_clone.clone();
|
|
let qn = qname.clone();
|
|
selected_node.set(Some(nc));
|
|
inspector_open.set(true);
|
|
search_results.set(Vec::new());
|
|
spawn(async move {
|
|
let names_json = serde_json::to_string(&vec![&qn]).unwrap_or_else(|_| "[]".into());
|
|
let js = format!("if (window.__highlightNodes) {{ window.__highlightNodes({names_json}); }}");
|
|
let _ = document::eval(&js);
|
|
});
|
|
},
|
|
span { class: "search-suggestion-name", "{name}" }
|
|
span { class: "search-suggestion-kind", "{kind}" }
|
|
span { class: "search-suggestion-path", "{file_path}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
button {
|
|
class: "btn-sm",
|
|
onclick: move |_| {
|
|
spawn(async move {
|
|
let _ = document::eval("if (window.__fitGraph) { window.__fitGraph(); }");
|
|
});
|
|
},
|
|
"Fit View"
|
|
}
|
|
}
|
|
|
|
// Hidden input for JS → Rust node click communication
|
|
input {
|
|
id: "graph-node-click-data",
|
|
r#type: "hidden",
|
|
value: "",
|
|
oninput: move |e| {
|
|
let val = e.value();
|
|
if !val.is_empty() {
|
|
if let Ok(node) = serde_json::from_str::<serde_json::Value>(&val) {
|
|
selected_node.set(Some(node));
|
|
inspector_open.set(true);
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
// 3-panel layout: file-tree | [code-inspector] | graph-canvas
|
|
div { class: if inspector_open() { "graph-explorer-layout inspector-open" } else { "graph-explorer-layout" },
|
|
|
|
// Left: File Tree
|
|
div { class: "file-tree-panel",
|
|
div { class: "file-tree-header",
|
|
h4 { "Files" }
|
|
}
|
|
div { class: "file-tree-search",
|
|
input {
|
|
r#type: "text",
|
|
placeholder: "Filter files...",
|
|
value: "{file_filter}",
|
|
oninput: move |e| file_filter.set(e.value()),
|
|
}
|
|
}
|
|
div { class: "file-tree-content",
|
|
if file_tree_data.is_empty() {
|
|
div { class: "file-tree-empty", "No files. Build the graph first." }
|
|
} else {
|
|
FileTree {
|
|
tree: file_tree_data,
|
|
filter: file_filter(),
|
|
on_file_click: move |path: String| {
|
|
// Open code inspector for this file
|
|
let file_node = serde_json::json!({
|
|
"file_path": &path,
|
|
"name": path.rsplit('/').next().unwrap_or(&path),
|
|
"kind": "file",
|
|
"start_line": 1,
|
|
"end_line": 0,
|
|
});
|
|
selected_node.set(Some(file_node));
|
|
inspector_open.set(true);
|
|
// Also highlight in graph
|
|
spawn(async move {
|
|
let js = format!(
|
|
"if (window.__highlightFileNodes) {{ window.__highlightFileNodes('{path}'); }}"
|
|
);
|
|
let _ = document::eval(&js);
|
|
});
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Left-center: Code Inspector (between file tree and graph)
|
|
if inspector_open() && selected_node().is_some() {
|
|
CodeInspector {
|
|
repo_id: repo_id.clone(),
|
|
file_path: sel_file,
|
|
node_name: sel_name,
|
|
node_kind: sel_kind,
|
|
start_line: sel_start,
|
|
end_line: sel_end,
|
|
on_close: move |_| {
|
|
inspector_open.set(false);
|
|
selected_node.set(None);
|
|
},
|
|
}
|
|
}
|
|
|
|
// Right: Graph Canvas
|
|
div { class: "graph-canvas-panel",
|
|
if has_graph_data {
|
|
div {
|
|
id: "graph-canvas",
|
|
class: "graph-canvas",
|
|
}
|
|
// Stabilization overlay
|
|
div {
|
|
id: "graph-stabilization-overlay",
|
|
class: "graph-stab-overlay",
|
|
div { class: "graph-stab-content",
|
|
// Orbital rings
|
|
div { class: "graph-stab-rings",
|
|
div { class: "graph-stab-ring graph-stab-ring-1" }
|
|
div { class: "graph-stab-ring graph-stab-ring-2" }
|
|
div { class: "graph-stab-ring graph-stab-ring-3" }
|
|
div { class: "graph-stab-core" }
|
|
}
|
|
div { class: "graph-stab-text",
|
|
div { class: "graph-stab-title", "Rendering Graph" }
|
|
div { class: "graph-stab-subtitle",
|
|
id: "graph-stab-node-info",
|
|
"{node_count} nodes \u{00B7} {edge_count} edges"
|
|
}
|
|
}
|
|
// Progress bar
|
|
div { class: "graph-stab-progress",
|
|
div {
|
|
class: "graph-stab-progress-fill",
|
|
id: "graph-stab-progress-fill",
|
|
}
|
|
}
|
|
div {
|
|
class: "graph-stab-pct",
|
|
id: "graph-stab-pct",
|
|
"Initializing\u{2026}"
|
|
}
|
|
}
|
|
}
|
|
} else if node_count > 0 {
|
|
// Data exists but nodes array was empty (shouldn't happen)
|
|
div { class: "loading", "Loading graph visualization..." }
|
|
} else if (*graph_data.read()).is_none() {
|
|
div { class: "loading", "Loading graph data..." }
|
|
} else {
|
|
div { class: "graph-empty-state",
|
|
div { class: "graph-empty-icon", "\u{29BB}" }
|
|
h3 { "No Graph Data" }
|
|
p { "Click \"Build Graph\" to analyze the repository's code structure." }
|
|
}
|
|
}
|
|
|
|
// Stats bar
|
|
if node_count > 0 {
|
|
div { class: "graph-stats-bar",
|
|
span { class: "graph-stat",
|
|
span { class: "graph-stat-value", "{node_count}" }
|
|
span { class: "graph-stat-label", " nodes" }
|
|
}
|
|
span { class: "graph-stat-divider", "|" }
|
|
span { class: "graph-stat",
|
|
span { class: "graph-stat-value", "{edge_count}" }
|
|
span { class: "graph-stat-label", " edges" }
|
|
}
|
|
span { class: "graph-stat-divider", "|" }
|
|
span { class: "graph-stat",
|
|
span { class: "graph-stat-value", "{community_count}" }
|
|
span { class: "graph-stat-label", " communities" }
|
|
}
|
|
if !languages.is_empty() {
|
|
span { class: "graph-stat-divider", "|" }
|
|
span { class: "graph-stat",
|
|
span { class: "graph-stat-label", "{languages.join(\", \")}" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
}
|