Enhance graph explorer: widen inspector, redesign index, add search suggestions
- Widen code inspector panel from 450px to 550px for better readability - Redesign graph index landing page with polished repo cards showing name, git URL, branch, findings count, and relative update time - Add search suggestions dropdown in graph explorer that appears on typing >= 2 chars, showing node name, kind badge, and file path - Add full graph explorer styles matching Obsidian Control dark theme Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
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, trigger_graph_build};
|
||||
use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_build};
|
||||
|
||||
#[component]
|
||||
pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
@@ -20,6 +22,129 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
let mut building = use_signal(|| false);
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
|
||||
// Selected node state
|
||||
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
||||
let mut inspector_open = use_signal(|| false);
|
||||
|
||||
// Search state
|
||||
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());
|
||||
|
||||
// Store serialized graph JSON in signals so use_effect can react to them
|
||||
let mut nodes_json = use_signal(|| String::new());
|
||||
let mut edges_json = use_signal(|| String::new());
|
||||
let mut graph_ready = use_signal(|| false);
|
||||
|
||||
// When resource resolves, serialize the data into signals
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive stats and file tree
|
||||
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 the read guard before rendering
|
||||
drop(graph_data_read);
|
||||
|
||||
// use_effect runs AFTER DOM commit — this is when #graph-canvas exists
|
||||
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 {
|
||||
// Register the click callback + load graph with a small delay for DOM paint
|
||||
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});
|
||||
}} else {{
|
||||
console.error('[graph-viz] __loadGraph not found — vis-network may not be loaded');
|
||||
}}
|
||||
}}, 300);
|
||||
"#
|
||||
);
|
||||
let _ = document::eval(&js);
|
||||
});
|
||||
});
|
||||
|
||||
// Extract selected node fields
|
||||
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! {
|
||||
PageHeader {
|
||||
title: "Code Knowledge Graph",
|
||||
@@ -29,10 +154,10 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
if repo_id.is_empty() {
|
||||
div { class: "card",
|
||||
p { "Select a repository to view its code graph." }
|
||||
p { "You can trigger a graph build from the Repositories page." }
|
||||
}
|
||||
} else {
|
||||
div { style: "margin-bottom: 16px;",
|
||||
// Toolbar
|
||||
div { class: "graph-toolbar",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: building(),
|
||||
@@ -41,6 +166,9 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
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"),
|
||||
@@ -53,53 +181,267 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
h3 { "Graph Explorer \u{2014} {repo_id}" }
|
||||
|
||||
match &*graph_data.read() {
|
||||
Some(Some(data)) => {
|
||||
let build = data.data.build.clone().unwrap_or_default();
|
||||
let node_count = build.get("node_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let edge_count = build.get("edge_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let community_count = build.get("community_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
});
|
||||
} 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: "grid grid-cols-3 gap-4 mb-4",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{node_count}" }
|
||||
div { class: "stat-label", "Nodes" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{edge_count}" }
|
||||
div { class: "stat-label", "Edges" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{community_count}" }
|
||||
div { class: "stat-label", "Communities" }
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
id: "graph-container",
|
||||
style: "width: 100%; height: 600px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary);",
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
script {
|
||||
r#"
|
||||
console.log('Graph explorer loaded');
|
||||
"#
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
p { "No graph data available. Build the graph first." }
|
||||
},
|
||||
None => rsx! {
|
||||
p { "Loading graph data..." }
|
||||
}
|
||||
|
||||
// 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 matches!(&*graph_data.read(), 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(\", \")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,28 +14,62 @@ pub fn GraphIndexPage() -> Element {
|
||||
description: "Select a repository to explore its code graph",
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
h3 { "Repositories" }
|
||||
match &*repos.read() {
|
||||
Some(Some(data)) => {
|
||||
let repo_list = &data.data;
|
||||
if repo_list.is_empty() {
|
||||
rsx! { p { "No repositories found. Add a repository first." } }
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
p { "No repositories found. Add a repository first." }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "grid grid-cols-1 gap-3",
|
||||
div { class: "graph-index-grid",
|
||||
for repo in repo_list {
|
||||
{
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let name = repo.name.clone();
|
||||
let url = repo.git_url.clone();
|
||||
let branch = repo.default_branch.clone();
|
||||
let findings = repo.findings_count;
|
||||
let findings_label = if findings != 1 { format!("{findings} findings") } else { "1 finding".to_string() };
|
||||
let updated = {
|
||||
let now = chrono::Utc::now();
|
||||
let diff = now.signed_duration_since(repo.updated_at);
|
||||
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! {
|
||||
Link {
|
||||
to: Route::GraphExplorerPage { repo_id: repo_id },
|
||||
class: "card hover:bg-gray-800 transition-colors cursor-pointer",
|
||||
h4 { "{name}" }
|
||||
to: Route::GraphExplorerPage { repo_id },
|
||||
class: "graph-repo-card",
|
||||
div { class: "graph-repo-card-header",
|
||||
div { class: "graph-repo-card-icon", "\u{29BB}" }
|
||||
h3 { class: "graph-repo-card-name", "{name}" }
|
||||
}
|
||||
if !url.is_empty() {
|
||||
p { class: "text-sm text-muted", "{url}" }
|
||||
p { class: "graph-repo-card-url", "{url}" }
|
||||
}
|
||||
div { class: "graph-repo-card-meta",
|
||||
span { class: "graph-repo-card-tag",
|
||||
"\u{E0A0} {branch}"
|
||||
}
|
||||
span { class: "graph-repo-card-tag graph-repo-card-tag-findings",
|
||||
"{findings_label}"
|
||||
}
|
||||
span { class: "graph-repo-card-tag",
|
||||
"Updated {updated}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,9 +79,12 @@ pub fn GraphIndexPage() -> Element {
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { "Failed to load repositories." } },
|
||||
None => rsx! { p { "Loading repositories..." } },
|
||||
}
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Failed to load repositories." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading repositories..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user