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::(); let mut selected_node = use_signal(|| Option::::None); let mut inspector_open = use_signal(|| false); let mut search_query = use_signal(String::new); let mut search_results = use_signal(Vec::::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 = 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 = 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::(&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(\", \")}" } } } } } } } } } }