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, } /// 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 { // Count nodes per file let mut file_counts: BTreeMap = 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 = 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 = root_children.into_values().collect(); sort_tree(&mut result); result } fn insert_path( children: &mut BTreeMap, 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 = 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, filter: String, on_file_click: EventHandler, ) -> 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, ) -> 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), } } } } } }