Skip to main content

reflex/pulse/
explorer.rs

1//! Explorer: Interactive treemap visualization
2//!
3//! Generates a nested treemap showing the entire codebase as rectangles
4//! proportional to line count, colored by language. Uses D3.js for
5//! client-side rendering. No LLM needed.
6
7use anyhow::{Context, Result};
8use rusqlite::Connection;
9use serde::Serialize;
10use std::collections::HashMap;
11
12use crate::cache::CacheManager;
13
14/// A node in the treemap hierarchy
15#[derive(Debug, Clone, Serialize)]
16pub struct TreemapNode {
17    pub name: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub value: Option<usize>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub language: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub path: Option<String>,
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub children: Vec<TreemapNode>,
26}
27
28/// Full explorer data
29#[derive(Debug, Clone)]
30pub struct ExplorerData {
31    pub root: TreemapNode,
32    pub language_colors: HashMap<String, String>,
33    pub total_files: usize,
34    pub total_lines: usize,
35}
36
37/// Generate treemap data from the index
38pub fn generate_explorer(cache: &CacheManager) -> Result<ExplorerData> {
39    let db_path = cache.path().join("meta.db");
40    let conn = Connection::open(&db_path)
41        .context("Failed to open meta.db")?;
42
43    // Query all files with line counts and languages
44    let mut stmt = conn.prepare(
45        "SELECT path, line_count, COALESCE(language, 'other') FROM files ORDER BY path"
46    )?;
47
48    let files: Vec<(String, usize, String)> = stmt.query_map([], |row| {
49        Ok((
50            row.get::<_, String>(0)?,
51            row.get::<_, usize>(1)?,
52            row.get::<_, String>(2)?,
53        ))
54    })?.filter_map(|r| r.ok()).collect();
55
56    let total_files = files.len();
57    let total_lines: usize = files.iter().map(|(_, lines, _)| lines).sum();
58
59    // Build tree hierarchy from file paths
60    let mut root = TreemapNode {
61        name: "root".to_string(),
62        value: None,
63        language: None,
64        path: None,
65        children: vec![],
66    };
67
68    for (path, lines, language) in &files {
69        let parts: Vec<&str> = path.split('/').collect();
70        insert_into_tree(&mut root, &parts, *lines, language);
71    }
72
73    // Collapse single-child directories for cleaner display
74    collapse_single_children(&mut root);
75
76    // Build language color map
77    let language_colors = build_language_colors(&files);
78
79    Ok(ExplorerData {
80        root,
81        language_colors,
82        total_files,
83        total_lines,
84    })
85}
86
87/// Insert a file into the tree hierarchy
88fn insert_into_tree(node: &mut TreemapNode, parts: &[&str], lines: usize, language: &str) {
89    if parts.is_empty() { return; }
90
91    if parts.len() == 1 {
92        // Leaf node (file)
93        node.children.push(TreemapNode {
94            name: parts[0].to_string(),
95            value: Some(lines),
96            language: Some(language.to_string()),
97            path: None, // Will be set during serialization
98            children: vec![],
99        });
100        return;
101    }
102
103    // Find or create directory node
104    let dir_name = parts[0];
105    let child = node.children.iter_mut().find(|c| c.name == dir_name && c.value.is_none());
106
107    if let Some(child) = child {
108        insert_into_tree(child, &parts[1..], lines, language);
109    } else {
110        let mut new_dir = TreemapNode {
111            name: dir_name.to_string(),
112            value: None,
113            language: None,
114            path: None,
115            children: vec![],
116        };
117        insert_into_tree(&mut new_dir, &parts[1..], lines, language);
118        node.children.push(new_dir);
119    }
120}
121
122/// Collapse directory nodes that have only one child directory
123fn collapse_single_children(node: &mut TreemapNode) {
124    // Recurse first
125    for child in &mut node.children {
126        collapse_single_children(child);
127    }
128
129    // If this directory has exactly one child that is also a directory, merge them
130    if node.children.len() == 1 && node.children[0].value.is_none() && node.name != "root" {
131        let child = node.children.remove(0);
132        node.name = format!("{}/{}", node.name, child.name);
133        node.children = child.children;
134    }
135}
136
137/// Assign colors to languages (Synthwave palette)
138fn build_language_colors(files: &[(String, usize, String)]) -> HashMap<String, String> {
139    let palette = [
140        "#a78bfa", // soft violet
141        "#4ade80", // soft green
142        "#f472b6", // soft pink
143        "#fbbf24", // warm amber
144        "#67e8f9", // soft cyan
145        "#fb923c", // soft orange
146        "#818cf8", // indigo
147        "#f9a8d4", // light pink
148        "#86efac", // mint green
149        "#c4b5fd", // light violet
150    ];
151
152    let mut lang_counts: HashMap<String, usize> = HashMap::new();
153    for (_, _, lang) in files {
154        *lang_counts.entry(lang.clone()).or_default() += 1;
155    }
156
157    let mut sorted: Vec<(String, usize)> = lang_counts.into_iter().collect();
158    sorted.sort_by(|a, b| b.1.cmp(&a.1));
159
160    sorted.into_iter()
161        .enumerate()
162        .map(|(i, (lang, _))| (lang, palette[i % palette.len()].to_string()))
163        .collect()
164}
165
166/// Generate treemap JSON for the D3.js visualization
167pub fn treemap_json(data: &ExplorerData) -> Result<String> {
168    serde_json::to_string(&data.root)
169        .context("Failed to serialize treemap data")
170}
171
172/// Render explorer page markdown with embedded D3.js treemap
173pub fn render_explorer_markdown(data: &ExplorerData) -> Result<String> {
174    let mut md = String::new();
175
176    md.push_str(&format!(
177        "Visual overview of the codebase: **{}** files, **{}** lines of code.\n\n",
178        data.total_files, data.total_lines
179    ));
180
181    md.push_str("Rectangles are proportional to line count. Colors represent languages. Click to zoom into a directory.\n\n");
182
183    // Language legend
184    md.push_str("### Languages\n\n");
185    let mut sorted_colors: Vec<(&String, &String)> = data.language_colors.iter().collect();
186    sorted_colors.sort_by_key(|(lang, _)| lang.to_lowercase());
187    for (lang, color) in &sorted_colors {
188        md.push_str(&format!(
189            "<span style=\"display:inline-block;width:12px;height:12px;background:{};border-radius:2px;margin-right:4px;\"></span> {}  \n",
190            color, lang
191        ));
192    }
193    md.push('\n');
194
195    // Treemap container
196    md.push_str("<div id=\"treemap-container\" style=\"width:100%;height:600px;background:var(--bg-surface);border-radius:8px;overflow:hidden;position:relative;\"></div>\n\n");
197
198    // Breadcrumb for navigation
199    md.push_str("<div id=\"treemap-breadcrumb\" style=\"padding:8px 0;color:var(--fg-muted);font-size:0.9em;\"></div>\n\n");
200
201    // Embed the treemap JSON and D3.js script
202    let json = treemap_json(data)?;
203    let colors_json = serde_json::to_string(&data.language_colors).unwrap_or_default();
204
205    md.push_str("<script type=\"module\">\n");
206    md.push_str("import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';\n\n");
207    md.push_str(&format!("const data = {};\n", json));
208    md.push_str(&format!("const colors = {};\n\n", colors_json));
209    md.push_str(r#"const container = document.getElementById('treemap-container');
210const breadcrumb = document.getElementById('treemap-breadcrumb');
211const width = container.clientWidth;
212const height = container.clientHeight;
213
214const root = d3.hierarchy(data)
215    .sum(d => d.value || 0)
216    .sort((a, b) => b.value - a.value);
217
218// Compute layout ONCE on the full tree
219d3.treemap()
220    .size([width, height])
221    .paddingOuter(3)
222    .paddingTop(19)
223    .paddingInner(1)
224    .round(true)(root);
225
226const svg = d3.select(container)
227    .append('svg')
228    .attr('viewBox', `0 0 ${width} ${height}`)
229    .attr('width', width)
230    .attr('height', height)
231    .style('font', '10px sans-serif');
232
233let currentRoot = root;
234
235function render(focus) {
236    svg.selectAll('*').remove();
237    currentRoot = focus;
238
239    // Coordinate-transform zoom: map focus bounds to fill viewport
240    const x = d3.scaleLinear().domain([focus.x0, focus.x1]).rangeRound([0, width]);
241    const y = d3.scaleLinear().domain([focus.y0, focus.y1]).rangeRound([0, height]);
242
243    // Get all descendants of focus that are directories (have children)
244    const groups = focus.descendants().filter(d => d.children && d !== focus);
245
246    // Draw directory group headers
247    groups.forEach(group => {
248        const gx = x(group.x0), gy = y(group.y0);
249        const gw = x(group.x1) - gx;
250        const gh = 18;
251        if (gw < 20) return;
252
253        svg.append('rect')
254            .attr('x', gx).attr('y', gy)
255            .attr('width', Math.max(0, gw))
256            .attr('height', gh)
257            .attr('fill', '#1a1a2e')
258            .style('cursor', 'pointer')
259            .on('click', () => render(group));
260
261        svg.append('text')
262            .attr('x', gx + 4).attr('y', gy + 13)
263            .attr('fill', '#a78bfa')
264            .attr('font-weight', 700)
265            .attr('font-size', '11px')
266            .style('cursor', 'pointer')
267            .text(() => {
268                const maxChars = Math.floor((gw - 8) / 6.5);
269                const name = group.data.name;
270                return name.length > maxChars ? name.slice(0, maxChars) : name;
271            })
272            .on('click', () => render(group));
273    });
274
275    // Draw leaf file cells
276    const leaves = focus.leaves();
277    const cell = svg.selectAll('g.leaf')
278        .data(leaves)
279        .join('g')
280        .attr('class', 'leaf')
281        .attr('transform', d => `translate(${x(d.x0)},${y(d.y0)})`);
282
283    const cellW = d => Math.max(0, x(d.x1) - x(d.x0));
284    const cellH = d => Math.max(0, y(d.y1) - y(d.y0));
285
286    cell.append('rect')
287        .attr('width', cellW)
288        .attr('height', cellH)
289        .attr('fill', d => colors[d.data.language] || '#2a2a4a')
290        .attr('opacity', 0.85)
291        .attr('rx', 2)
292        .style('cursor', 'pointer')
293        .on('click', (event, d) => {
294            // Click a file: zoom into its parent directory (if not already the focus)
295            if (d.parent && d.parent !== focus) {
296                render(d.parent);
297            } else if (focus.parent) {
298                // Already at this level — zoom back out
299                render(focus.parent);
300            }
301        });
302
303    cell.append('title')
304        .text(d => `${d.ancestors().reverse().map(d => d.data.name).join('/')}\n${(d.value || 0).toLocaleString()} lines`);
305
306    cell.filter(d => cellW(d) > 40 && cellH(d) > 14)
307        .append('text')
308        .attr('x', 3)
309        .attr('y', 12)
310        .attr('fill', '#0d0d0d')
311        .attr('font-weight', 600)
312        .text(d => {
313            const w = cellW(d) - 6;
314            const name = d.data.name;
315            return name.length * 6 > w ? name.slice(0, Math.floor(w / 6)) : name;
316        });
317
318    // Update breadcrumb — all ancestors are clickable to zoom out
319    const pathArr = [];
320    let node = focus;
321    while (node) {
322        pathArr.unshift(node);
323        node = node.parent;
324    }
325    breadcrumb.innerHTML = pathArr.map((n, i) => {
326        if (i < pathArr.length - 1) {
327            return '<a href="javascript:void(0)" style="color:var(--fg-accent);text-decoration:none;">' + n.data.name + '</a>';
328        }
329        return '<span style="color:var(--fg);font-weight:600;">' + n.data.name + '</span>';
330    }).join(' / ');
331
332    const links = breadcrumb.querySelectorAll('a');
333    links.forEach((link, i) => {
334        link.onclick = (e) => {
335            e.preventDefault();
336            render(pathArr[i]);
337        };
338    });
339}
340
341render(root);
342
343// Double-click resets to root
344container.addEventListener('dblclick', () => render(root));
345</script>
346"#);
347
348    Ok(md)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_insert_into_tree() {
357        let mut root = TreemapNode {
358            name: "root".to_string(),
359            value: None,
360            language: None,
361            path: None,
362            children: vec![],
363        };
364        insert_into_tree(&mut root, &["src", "main.rs"], 100, "Rust");
365        assert_eq!(root.children.len(), 1);
366        assert_eq!(root.children[0].name, "src");
367        assert_eq!(root.children[0].children.len(), 1);
368        assert_eq!(root.children[0].children[0].name, "main.rs");
369        assert_eq!(root.children[0].children[0].value, Some(100));
370    }
371
372    #[test]
373    fn test_collapse_single_children() {
374        let mut root = TreemapNode {
375            name: "root".to_string(),
376            value: None,
377            language: None,
378            path: None,
379            children: vec![TreemapNode {
380                name: "src".to_string(),
381                value: None,
382                language: None,
383                path: None,
384                children: vec![TreemapNode {
385                    name: "lib".to_string(),
386                    value: None,
387                    language: None,
388                    path: None,
389                    children: vec![TreemapNode {
390                        name: "main.rs".to_string(),
391                        value: Some(100),
392                        language: Some("Rust".to_string()),
393                        path: None,
394                        children: vec![],
395                    }],
396                }],
397            }],
398        };
399        collapse_single_children(&mut root);
400        // src -> lib should be collapsed to src/lib
401        assert_eq!(root.children[0].name, "src/lib");
402    }
403
404    #[test]
405    fn test_build_language_colors() {
406        let files = vec![
407            ("a.rs".to_string(), 100, "Rust".to_string()),
408            ("b.py".to_string(), 50, "Python".to_string()),
409        ];
410        let colors = build_language_colors(&files);
411        assert!(colors.contains_key("Rust"));
412        assert!(colors.contains_key("Python"));
413    }
414}