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:
Sharang Parnerkar
2026-03-04 21:53:15 +01:00
parent b18824db25
commit 0a365515e9
3 changed files with 1884 additions and 189 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
use dioxus::prelude::*; 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::page_header::PageHeader;
use crate::components::toast::{ToastType, Toasts}; 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] #[component]
pub fn GraphExplorerPage(repo_id: String) -> Element { 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 building = use_signal(|| false);
let mut toasts = use_context::<Toasts>(); 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! { rsx! {
PageHeader { PageHeader {
title: "Code Knowledge Graph", title: "Code Knowledge Graph",
@@ -29,10 +154,10 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
if repo_id.is_empty() { if repo_id.is_empty() {
div { class: "card", div { class: "card",
p { "Select a repository to view its code graph." } p { "Select a repository to view its code graph." }
p { "You can trigger a graph build from the Repositories page." }
} }
} else { } else {
div { style: "margin-bottom: 16px;", // Toolbar
div { class: "graph-toolbar",
button { button {
class: "btn btn-primary", class: "btn btn-primary",
disabled: building(), disabled: building(),
@@ -41,6 +166,9 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
move |_| { move |_| {
let rid = rid.clone(); let rid = rid.clone();
building.set(true); building.set(true);
graph_ready.set(false);
nodes_json.set(String::new());
edges_json.set(String::new());
spawn(async move { spawn(async move {
match trigger_graph_build(rid).await { match trigger_graph_build(rid).await {
Ok(_) => toasts.push(ToastType::Success, "Graph build triggered"), Ok(_) => toasts.push(ToastType::Success, "Graph build triggered"),
@@ -53,52 +181,266 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
}, },
if building() { "Building..." } else { "Build Graph" } if building() { "Building..." } else { "Build Graph" }
} }
}
div { class: "card", div { class: "graph-search-bar",
h3 { "Graph Explorer \u{2014} {repo_id}" } input {
r#type: "text",
match &*graph_data.read() { placeholder: "Search nodes...",
Some(Some(data)) => { value: "{search_query}",
let build = data.data.build.clone().unwrap_or_default(); oninput: {
let node_count = build.get("node_count").and_then(|n| n.as_u64()).unwrap_or(0); let rid = repo_id.clone();
let edge_count = build.get("edge_count").and_then(|n| n.as_u64()).unwrap_or(0); move |e: FormEvent| {
let community_count = build.get("community_count").and_then(|n| n.as_u64()).unwrap_or(0); let val = e.value();
rsx! { search_query.set(val.clone());
div { class: "grid grid-cols-3 gap-4 mb-4", if val.len() >= 2 {
div { class: "stat-card", let rid = rid.clone();
div { class: "stat-value", "{node_count}" } spawn(async move {
div { class: "stat-label", "Nodes" } if let Ok(resp) = search_nodes(rid, val).await {
} search_results.set(resp.data);
div { class: "stat-card", }
div { class: "stat-value", "{edge_count}" } });
div { class: "stat-label", "Edges" } } else {
} search_results.set(Vec::new());
div { class: "stat-card",
div { class: "stat-value", "{community_count}" }
div { class: "stat-label", "Communities" }
} }
} }
},
div { onkeypress: {
id: "graph-container", let rid = repo_id.clone();
style: "width: 100%; height: 600px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary);", 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(); }");
});
}
}
} }
},
script { }
r#" if !search_results().is_empty() {
console.log('Graph explorer loaded'); 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}" }
}
}
}
} }
} }
}, }
Some(None) => rsx! {
p { "No graph data available. Build the graph first." }
},
None => rsx! {
p { "Loading graph data..." }
},
} }
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 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(\", \")}" }
}
}
}
}
}
} }
} }
} }

View File

@@ -14,28 +14,61 @@ pub fn GraphIndexPage() -> Element {
description: "Select a repository to explore its code graph", description: "Select a repository to explore its code graph",
} }
div { class: "card", match &*repos.read() {
h3 { "Repositories" } Some(Some(data)) => {
match &*repos.read() { let repo_list = &data.data;
Some(Some(data)) => { if repo_list.is_empty() {
let repo_list = &data.data; rsx! {
if repo_list.is_empty() { div { class: "card",
rsx! { p { "No repositories found. Add a repository first." } } p { "No repositories found. Add a repository first." }
} else { }
rsx! { }
div { class: "grid grid-cols-1 gap-3", } else {
for repo in repo_list { rsx! {
{ div { class: "graph-index-grid",
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); for repo in repo_list {
let name = repo.name.clone(); {
let url = repo.git_url.clone(); let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
rsx! { let name = repo.name.clone();
Link { let url = repo.git_url.clone();
to: Route::GraphExplorerPage { repo_id: repo_id }, let branch = repo.default_branch.clone();
class: "card hover:bg-gray-800 transition-colors cursor-pointer", let findings = repo.findings_count;
h4 { "{name}" } let findings_label = if findings != 1 { format!("{findings} findings") } else { "1 finding".to_string() };
if !url.is_empty() { let updated = {
p { class: "text-sm text-muted", "{url}" } 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 },
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: "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}"
} }
} }
} }
@@ -44,10 +77,14 @@ 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..." }
},
} }
} }
} }