1use std::collections::{HashMap, HashSet};
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use git2::{Patch, TreeWalkMode, TreeWalkResult};
5use ratatui::Frame;
6use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};
7use ratatui::style::Color::{self};
8use ratatui::style::palette::material::WHITE;
9use ratatui::style::{Modifier, Style, Stylize};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{
12 Block, Borders, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
13 TableState,
14};
15
16use crate::error::Result;
17use crate::tui::ACCENT;
18use crate::{git::kit::KitRepo, tui::Renderable};
19
20#[derive(Default)]
21pub struct SiloData {
22 pub files: Vec<FileSilo>,
23}
24
25#[derive(Default, Debug)]
26pub struct FileSilo {
27 pub file: String,
28 pub gatekeeper: String,
29 pub contributors: u16,
30 pub risk: u8,
31 pub total_churn: usize,
32 pub author_churn: HashMap<String, usize>,
33}
34
35impl SiloData {
36 pub fn new(repo: &KitRepo) -> Self {
37 SiloData::get_churn(repo).unwrap_or_default()
38 }
39
40 pub fn get_churn(repo: &KitRepo) -> Result<Self> {
41 let head_files = Self::get_head_files(repo)?;
42
43 let raw_churn_map = Self::accumulate_churn(repo)?;
44
45 let active_files = Self::process_silos(raw_churn_map, &head_files);
46
47 Ok(Self {
48 files: active_files,
49 })
50 }
51
52 pub fn get_head_files(repo: &KitRepo) -> Result<HashSet<String>> {
53 let mut current_files = HashSet::new();
54 let head = repo.inner.head()?;
55 let head_tree = head.peel_to_tree()?;
56
57 head_tree.walk(TreeWalkMode::PreOrder, |root, entry| {
58 if entry.kind() == Some(git2::ObjectType::Blob) {
59 if let Some(name) = entry.name().ok() {
60 current_files.insert(format!("{}{}", root, name));
61 }
62 }
63 TreeWalkResult::Ok
64 })?;
65
66 Ok(current_files)
67 }
68
69 pub fn accumulate_churn(repo: &KitRepo) -> Result<HashMap<String, HashMap<String, usize>>> {
70 let mut churn_map: HashMap<String, HashMap<String, usize>> = HashMap::new();
71
72 for (commit, diff) in repo.iter_diff_history()? {
73 let author_name = commit.email;
74
75 for i in 0..diff.deltas().len() {
76 if let Ok(Some(patch)) = Patch::from_diff(&diff, i) {
77 if let Some(path) = patch.delta().new_file().path() {
78 let file_path = path.to_string_lossy().to_string();
79
80 if let Ok((insertions, deletions, _)) = patch.line_stats() {
81 let churn = insertions + deletions;
82
83 if churn > 0 {
84 *churn_map
85 .entry(file_path)
86 .or_default()
87 .entry(author_name.clone())
88 .or_default() += churn;
89 }
90 }
91 }
92 }
93 }
94 }
95
96 Ok(churn_map)
97 }
98
99 pub fn process_silos(
100 churn_map: HashMap<String, HashMap<String, usize>>,
101 head_files: &HashSet<String>,
102 ) -> Vec<FileSilo> {
103 let mut active_files = Vec::new();
104
105 for (file, author_churn) in churn_map {
106 if !head_files.contains(&file) {
107 continue;
108 }
109
110 let total_churn: usize = author_churn.values().sum();
111 let contributors = author_churn.len() as u16;
112
113 let mut gatekeeper = String::from("Unknown");
114 let mut top_churn = 0;
115
116 for (author, churn) in &author_churn {
117 if *churn > top_churn {
118 top_churn = *churn;
119 gatekeeper = author.clone();
120 }
121 }
122
123 let risk = if total_churn > 0 {
124 ((top_churn as f64 / total_churn as f64) * 100.0).round() as u8
125 } else {
126 0
127 };
128
129 active_files.push(FileSilo {
130 file,
131 gatekeeper,
132 contributors,
133 risk,
134 total_churn,
135 author_churn,
136 });
137 }
138
139 active_files.sort_by(|a, b| b.risk.cmp(&a.risk).then(b.total_churn.cmp(&a.total_churn)));
140
141 active_files
142 }
143}
144
145pub struct SiloPage {
146 data: SiloData,
147 scroll_state: ScrollbarState,
148 table_state: TableState,
149 selected_index: usize,
150}
151
152impl Renderable for SiloPage {
153 fn render(&mut self, frame: &mut Frame, area: Rect) {
154 let chunks = Layout::vertical(vec![
155 Constraint::Percentage(50),
156 Constraint::Length(1),
157 Constraint::Percentage(50),
158 ])
159 .split(area);
160
161 self.render_churn_table(frame, chunks[0]);
162 self.render_churn_info(frame, chunks[2]);
163 }
164}
165
166impl SiloPage {
167 pub fn new(data: SiloData) -> Self {
168 let churn_size = &data.files.len();
169 let scroll_state = ScrollbarState::new(churn_size.clone()).position(0);
170 let table_state = TableState::default().with_selected(0);
171 Self {
172 data,
173 scroll_state,
174 table_state,
175 selected_index: 0,
176 }
177 }
178
179 pub fn handle_key(&mut self, key_event: KeyEvent, repo: &KitRepo) {
180 match (key_event.code, key_event.modifiers) {
181 (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => self.next(1),
182 (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => self.prev(1),
183
184 (KeyCode::Char('g'), KeyModifiers::NONE) => self.top(),
185 (KeyCode::Char('G'), _) | (KeyCode::Char('g'), KeyModifiers::SHIFT) => self.bottom(),
187
188 (KeyCode::Char('J'), _) | (KeyCode::Char('j'), KeyModifiers::SHIFT) => self.next(5),
190 (KeyCode::Char('K'), _) | (KeyCode::Char('k'), KeyModifiers::SHIFT) => self.prev(5),
191 _ => {}
192 }
193 }
194
195 fn select_index(&mut self, index: usize) {
196 self.table_state.select(Some(self.selected_index));
197 self.scroll_state = self.scroll_state.position(self.selected_index);
198 }
199
200 pub fn top(&mut self) {
201 if !self.data.files.is_empty() {
202 self.selected_index = 0;
203 self.select_index(self.selected_index);
204 }
205 }
206
207 pub fn bottom(&mut self) {
208 if !self.data.files.is_empty() {
209 self.selected_index = self.data.files.len() - 1;
210 self.select_index(self.selected_index);
211 }
212 }
213
214 pub fn next(&mut self, skip: usize) {
215 if !self.data.files.is_empty() {
216 self.selected_index = (self.selected_index + skip) % self.data.files.len();
217 self.select_index(self.selected_index);
218 }
219 }
220
221 pub fn prev(&mut self, skip: usize) {
222 if !self.data.files.is_empty() {
223 let len = self.data.files.len();
224 if self.selected_index < skip {
225 self.selected_index = (len + self.selected_index - (skip % len)) % len;
226 } else {
227 self.selected_index -= skip;
228 }
229
230 self.select_index(self.selected_index);
231 }
232 }
233
234 pub fn render_scrollbar(&mut self, frame: &mut Frame, area: Rect) {
235 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
236 self.scroll_state = self
237 .scroll_state
238 .viewport_content_length(area.height as usize);
239 frame.render_stateful_widget(
240 scrollbar,
241 area.inner(Margin {
242 vertical: 1,
243 horizontal: 0,
244 }),
245 &mut self.scroll_state.position(self.selected_index),
246 );
247 }
248
249 pub fn render_churn_table(&mut self, frame: &mut Frame, area: Rect) {
250 let rows: Vec<Row> = self
251 .data
252 .files
253 .iter()
254 .map(|churn| {
255 let ratio = churn.risk as f64 / 100.0;
256 let bar = generate_silo_bar(ratio, 20); Row::new(vec![
258 format!("{}", churn.file).fg(WHITE),
259 format!("{}", churn.gatekeeper).fg(WHITE),
260 format!("{}", churn.contributors).fg(WHITE),
261 format!("{} {}%", bar, churn.risk).into(),
262 ])
263 })
264 .collect();
265
266 let widths = [
267 Constraint::Percentage(50),
268 Constraint::Length(20),
269 Constraint::Length(20),
270 Constraint::Min(0),
271 ];
272 let table = Table::new(rows, widths)
273 .header(Row::new(vec![
274 "PATH".bold(),
275 "GATEKEEPER".bold(),
276 "CONTRIBUTORS".bold(),
277 "SILO RISK".bold(),
278 ]))
279 .block(
280 Block::bordered()
281 .title("Silos")
282 .title_alignment(Alignment::Left),
283 )
284 .row_highlight_style(ACCENT)
285 .highlight_symbol("> ");
286
287 frame.render_stateful_widget(table, area, &mut self.table_state);
288
289 self.render_scrollbar(frame, area);
290 }
291
292 pub fn render_churn_info(&self, frame: &mut Frame, area: Rect) {
293 let chunks =
294 Layout::horizontal(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
295 .split(area);
296
297 let left = chunks[0];
298 let right = chunks[1];
299
300 self.render_foo(frame, left);
301
302 let block = Block::bordered();
303
304 frame.render_widget(block, right);
305 }
306
307 pub fn render_foo(&self, frame: &mut Frame, area: Rect) {
308 let silo = match self.data.files.get(self.selected_index) {
309 Some(silo) => silo,
310 None => return,
311 };
312
313 let mut top_contributors: Vec<(&String, &usize)> = silo.author_churn.iter().collect();
314 top_contributors.sort_by(|a, b| b.1.cmp(a.1));
315
316 let mut info_lines = vec![
317 Line::from(vec![
318 Span::styled("Total File Churn: ", Style::default().fg(Color::White)),
319 Span::styled(
320 silo.total_churn.to_string(),
321 Style::default().fg(Color::White),
322 ),
323 Span::raw(" lines"),
324 ]),
325 Line::from(""),
326 Line::from(Span::styled(
327 "Top Contributors:",
328 Style::default().add_modifier(Modifier::BOLD),
329 )),
330 ];
331
332 for (author, churn) in top_contributors.iter().take(3) {
333 let percentage = (**churn as f64 / silo.total_churn as f64) * 100.0;
334 info_lines.push(Line::from(format!(
335 " - {}: {} lines ({:.0}%)",
336 author, churn, percentage
337 )));
338 }
339
340 let info_paragraph = Paragraph::new(info_lines).block(
341 Block::default()
342 .borders(Borders::ALL)
343 .title(silo.file.clone())
344 .style(Style::default().fg(Color::Gray)),
345 );
346
347 frame.render_widget(info_paragraph, area);
348 }
349}
350
351fn generate_silo_bar(percentage: f64, width: usize) -> String {
352 let filled = ((percentage) * width as f64).round() as usize;
353 let empty = width.saturating_sub(filled);
354
355 let filled_blocks = "█".repeat(filled);
356 let empty_blocks = "░".repeat(empty);
357 format!("[{}{}]", filled_blocks, empty_blocks)
358}