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