Some checks failed
CI / Format (push) Successful in 2s
CI / Clippy (push) Failing after 1m23s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Clippy (pull_request) Failing after 1m18s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
CI / Format (pull_request) Successful in 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
179 lines
5.1 KiB
Rust
179 lines
5.1 KiB
Rust
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<FileTreeNode>,
|
|
}
|
|
|
|
/// 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<FileTreeNode> {
|
|
// Count nodes per file
|
|
let mut file_counts: BTreeMap<String, usize> = 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<String, FileTreeNode> = 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<FileTreeNode> = root_children.into_values().collect();
|
|
sort_tree(&mut result);
|
|
result
|
|
}
|
|
|
|
fn insert_path(
|
|
children: &mut BTreeMap<String, FileTreeNode>,
|
|
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<String, FileTreeNode> = 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<FileTreeNode>,
|
|
filter: String,
|
|
on_file_click: EventHandler<String>,
|
|
) -> 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<String>,
|
|
) -> 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),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|