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).context("Failed to open meta.db")?;
41
42 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 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_children(&mut root);
76
77 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
88fn 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 node.children.push(TreemapNode {
97 name: parts[0].to_string(),
98 value: Some(lines),
99 language: Some(language.to_string()),
100 path: None, children: vec![],
102 });
103 return;
104 }
105
106 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
128fn collapse_single_children(node: &mut TreemapNode) {
130 for child in &mut node.children {
132 collapse_single_children(child);
133 }
134
135 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
143fn build_language_colors(files: &[(String, usize, String)]) -> HashMap<String, String> {
145 let palette = [
146 "#a78bfa", "#4ade80", "#f472b6", "#fbbf24", "#67e8f9", "#fb923c", "#818cf8", "#f9a8d4", "#86efac", "#c4b5fd", ];
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
173pub fn treemap_json(data: &ExplorerData) -> Result<String> {
175 serde_json::to_string(&data.root).context("Failed to serialize treemap data")
176}
177
178pub 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 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 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 md.push_str("<div id=\"treemap-breadcrumb\" style=\"padding:8px 0;color:var(--fg-muted);font-size:0.9em;\"></div>\n\n");
206
207 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 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}