1use anyhow::{Context, Result};
8use rusqlite::Connection;
9use serde::Serialize;
10use std::collections::HashMap;
11
12use crate::cache::CacheManager;
13
14#[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#[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
37pub 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 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 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_children(&mut root);
75
76 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
87fn 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 node.children.push(TreemapNode {
94 name: parts[0].to_string(),
95 value: Some(lines),
96 language: Some(language.to_string()),
97 path: None, children: vec![],
99 });
100 return;
101 }
102
103 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
122fn collapse_single_children(node: &mut TreemapNode) {
124 for child in &mut node.children {
126 collapse_single_children(child);
127 }
128
129 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
137fn build_language_colors(files: &[(String, usize, String)]) -> HashMap<String, String> {
139 let palette = [
140 "#a78bfa", "#4ade80", "#f472b6", "#fbbf24", "#67e8f9", "#fb923c", "#818cf8", "#f9a8d4", "#86efac", "#c4b5fd", ];
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
166pub fn treemap_json(data: &ExplorerData) -> Result<String> {
168 serde_json::to_string(&data.root)
169 .context("Failed to serialize treemap data")
170}
171
172pub 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 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 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 md.push_str("<div id=\"treemap-breadcrumb\" style=\"padding:8px 0;color:var(--fg-muted);font-size:0.9em;\"></div>\n\n");
200
201 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 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}