gitkraft_tui/features/diff/
view.rs1use ratatui::layout::{Constraint, Direction, Layout, Rect};
2use ratatui::style::{Modifier, Style};
3use ratatui::text::{Line, Span};
4use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
5use ratatui::Frame;
6
7use gitkraft_core::DiffLine;
8
9use crate::app::{ActivePane, App, DiffSubPane};
10
11pub fn render(app: &mut App, frame: &mut Frame, area: Rect) {
18 if !app.tab().commit_range_diffs.is_empty() {
20 render_commit_range_diff(app, frame, area);
21 return;
22 }
23
24 let theme = app.theme();
25 let is_active = app.active_pane == ActivePane::DiffView;
26 let sub_pane = app.tab().diff_sub_pane.clone();
27
28 let file_list_border = if is_active && sub_pane == DiffSubPane::FileList {
29 theme.border_active
30 } else {
31 theme.border_inactive
32 };
33 let content_border = if is_active && sub_pane == DiffSubPane::Content {
34 theme.border_active
35 } else {
36 theme.border_inactive
37 };
38
39 if !app.tab().commit_files.is_empty() {
40 let chunks = Layout::default()
41 .direction(Direction::Horizontal)
42 .constraints([Constraint::Length(30), Constraint::Min(20)])
43 .split(area);
44 render_file_list(app, frame, chunks[0], file_list_border);
45 render_diff_content(app, frame, chunks[1], content_border);
46 } else {
47 render_diff_content(app, frame, area, content_border);
48 }
49}
50
51fn render_file_list(
53 app: &mut App,
54 frame: &mut Frame,
55 area: Rect,
56 border_color: ratatui::style::Color,
57) {
58 let theme = app.theme();
59 let tab = app.tab();
60 let commit_diff_file_index = tab.commit_diff_file_index;
61 let is_active_file_list = app.active_pane == crate::app::ActivePane::DiffView
62 && tab.diff_sub_pane == crate::app::DiffSubPane::FileList;
63
64 let mut sorted_selected: Vec<usize> = tab.selected_file_indices.iter().copied().collect();
66 sorted_selected.sort_unstable();
67 let multi = sorted_selected.len() >= 2;
68
69 let title = if multi {
70 format!(
71 " Files ({}) — {} selected [J/K shrink · e open all] ",
72 tab.commit_files.len(),
73 sorted_selected.len()
74 )
75 } else if is_active_file_list {
76 format!(" Files ({}) [J/K select · e open] ", tab.commit_files.len())
77 } else {
78 format!(" Files ({}) ", tab.commit_files.len())
79 };
80
81 let block = Block::default()
82 .title(title)
83 .borders(Borders::ALL)
84 .border_style(Style::default().fg(border_color))
85 .style(Style::default().bg(theme.bg));
86
87 let items: Vec<ListItem> = tab
88 .commit_files
89 .iter()
90 .enumerate()
91 .map(|(i, diff)| {
92 let is_current = i == commit_diff_file_index;
93 let is_multi = tab.selected_file_indices.contains(&i);
94 let file_name = diff.file_name();
95 let status_char = format!("{}", diff.status);
96
97 let status_color = match diff.status.color_category() {
98 gitkraft_core::StatusColorCategory::Added => theme.success,
99 gitkraft_core::StatusColorCategory::Modified => theme.warning,
100 gitkraft_core::StatusColorCategory::Deleted => theme.error,
101 gitkraft_core::StatusColorCategory::Renamed => theme.accent,
102 };
103
104 let name_style = if is_current {
105 Style::default().fg(theme.text_primary)
106 } else if is_multi {
107 Style::default()
108 .fg(theme.accent)
109 .add_modifier(Modifier::BOLD)
110 } else {
111 Style::default().fg(theme.text_secondary)
112 };
113
114 let badge = if let Some(pos) = sorted_selected.iter().position(|&s| s == i) {
116 if multi {
117 format!("{:<2}", pos + 1)
118 } else {
119 "● ".to_string()
120 }
121 } else {
122 " ".to_string()
123 };
124
125 let line = Line::from(vec![
126 Span::styled(
127 badge,
128 Style::default()
129 .fg(theme.accent)
130 .add_modifier(Modifier::BOLD),
131 ),
132 Span::styled(
133 format!("{} ", status_char),
134 Style::default()
135 .fg(status_color)
136 .add_modifier(Modifier::BOLD),
137 ),
138 Span::styled(file_name.to_string(), name_style),
139 ]);
140
141 ListItem::new(line)
142 })
143 .collect();
144
145 let mut list_state = ratatui::widgets::ListState::default();
146 list_state.select(Some(commit_diff_file_index));
147
148 let list = List::new(items)
149 .block(block)
150 .highlight_style(
151 Style::default()
152 .bg(theme.sel_bg)
153 .add_modifier(Modifier::REVERSED),
154 )
155 .highlight_symbol("▸ ");
156
157 frame.render_stateful_widget(list, area, &mut list_state);
158}
159
160fn render_commit_range_diff(app: &mut App, frame: &mut Frame, area: Rect) {
162 let theme = app.theme();
163 let is_active = app.active_pane == ActivePane::DiffView;
164 let border_color = if is_active {
165 theme.border_active
166 } else {
167 theme.border_inactive
168 };
169
170 let count = app.tab().selected_commits.len();
171 let title = format!(" Combined diff ({} commits) ", count);
172
173 let block = Block::default()
174 .title(title)
175 .borders(Borders::ALL)
176 .border_style(Style::default().fg(border_color))
177 .style(Style::default().bg(theme.bg));
178
179 let diffs = app.tab().commit_range_diffs.clone();
180 let mut lines: Vec<Line> = Vec::new();
181
182 for diff in &diffs {
183 lines.push(Line::from(Span::styled(
185 format!("══ {} ══", diff.display_path()),
186 Style::default()
187 .fg(theme.diff_hunk)
188 .add_modifier(Modifier::BOLD),
189 )));
190
191 for hunk in &diff.hunks {
192 for line in &hunk.lines {
193 lines.push(styled_diff_line(line, &theme));
194 }
195 }
196 lines.push(Line::default()); }
198
199 let content_height = lines.len() as u16;
201 let visible_height = area.height.saturating_sub(2);
202 {
203 let tab = app.tab_mut();
204 if content_height > visible_height {
205 if tab.diff_scroll > content_height.saturating_sub(visible_height) {
206 tab.diff_scroll = content_height.saturating_sub(visible_height);
207 }
208 } else {
209 tab.diff_scroll = 0;
210 }
211 }
212
213 let scroll = app.tab().diff_scroll;
214 let paragraph = Paragraph::new(lines)
215 .block(block)
216 .wrap(Wrap { trim: false })
217 .scroll((scroll, 0));
218
219 frame.render_widget(paragraph, area);
220}
221
222fn styled_diff_line(
224 line: &DiffLine,
225 theme: &crate::features::theme::palette::UiTheme,
226) -> Line<'static> {
227 match line {
228 DiffLine::Addition(s) => Line::from(Span::styled(
229 format!("+{}", s),
230 Style::default().fg(theme.diff_add),
231 )),
232 DiffLine::Deletion(s) => Line::from(Span::styled(
233 format!("-{}", s),
234 Style::default().fg(theme.diff_del),
235 )),
236 DiffLine::Context(s) => Line::from(Span::styled(
237 format!(" {}", s),
238 Style::default().fg(theme.diff_context),
239 )),
240 DiffLine::HunkHeader(s) => Line::from(Span::styled(
241 s.clone(),
242 Style::default()
243 .fg(theme.diff_hunk)
244 .add_modifier(Modifier::BOLD),
245 )),
246 }
247}
248
249pub fn render_file_history(app: &mut App, frame: &mut Frame, area: Rect) {
251 let theme = app.theme();
252 let is_active = app.active_pane == ActivePane::DiffView;
253 let border_color = if is_active {
254 theme.border_active
255 } else {
256 theme.border_inactive
257 };
258
259 let path = app.tab().file_history_path.clone().unwrap_or_default();
260 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
261
262 let title = format!(" File History: {file_name} Esc close Enter select ");
263
264 let block = Block::default()
265 .title(title)
266 .borders(Borders::ALL)
267 .border_style(Style::default().fg(border_color))
268 .style(Style::default().bg(theme.bg));
269
270 let cursor = app.tab().file_history_cursor;
271 let commits = app.tab().file_history_commits.clone();
272
273 if commits.is_empty() {
274 let p = Paragraph::new(Line::from(Span::styled(
275 "Loading… (or no commits touch this file)",
276 Style::default().fg(theme.text_muted),
277 )))
278 .block(block);
279 frame.render_widget(p, area);
280 return;
281 }
282
283 let items: Vec<ListItem> = commits
284 .iter()
285 .enumerate()
286 .map(|(i, c)| {
287 let is_sel = i == cursor;
288 let style = if is_sel {
289 Style::default()
290 .fg(theme.text_primary)
291 .bg(theme.sel_bg)
292 .add_modifier(Modifier::BOLD)
293 } else {
294 Style::default().fg(theme.text_primary)
295 };
296 let rel = c.relative_time();
297 let summary = gitkraft_core::truncate_str(&c.summary, 48);
298 let line = Line::from(vec![
299 Span::styled(
300 format!("{} ", c.short_oid),
301 Style::default().fg(theme.warning),
302 ),
303 Span::styled(summary, style),
304 Span::styled(
305 format!(" ({}, {})", c.author_name, rel),
306 Style::default().fg(theme.text_muted),
307 ),
308 ]);
309 ListItem::new(line)
310 })
311 .collect();
312
313 let list = List::new(items)
314 .block(block)
315 .highlight_style(
316 Style::default()
317 .bg(theme.sel_bg)
318 .add_modifier(Modifier::REVERSED),
319 )
320 .highlight_symbol("▶ ");
321
322 let mut list_state = ratatui::widgets::ListState::default();
323 list_state.select(Some(cursor));
324 frame.render_stateful_widget(list, area, &mut list_state);
325}
326
327pub fn render_blame(app: &mut App, frame: &mut Frame, area: Rect) {
329 let theme = app.theme();
330 let is_active = app.active_pane == ActivePane::DiffView;
331 let border_color = if is_active {
332 theme.border_active
333 } else {
334 theme.border_inactive
335 };
336
337 let path = app.tab().blame_path.clone().unwrap_or_default();
338 let file_name = path.rsplit('/').next().unwrap_or(&path).to_string();
339 let title = format!(" Blame: {file_name} Esc close j/k scroll ");
340
341 let block = Block::default()
342 .title(title)
343 .borders(Borders::ALL)
344 .border_style(Style::default().fg(border_color))
345 .style(Style::default().bg(theme.bg));
346
347 let lines_data = app.tab().blame_lines.clone();
348
349 if lines_data.is_empty() {
350 let p = Paragraph::new(Line::from(Span::styled(
351 "Loading blame…",
352 Style::default().fg(theme.text_muted),
353 )))
354 .block(block);
355 frame.render_widget(p, area);
356 return;
357 }
358
359 let lines: Vec<Line> = lines_data
360 .iter()
361 .map(|bl| {
362 let rel = bl.relative_time();
363 let author = gitkraft_core::truncate_str(&bl.author_name, 12);
364 Line::from(vec![
365 Span::styled(
366 format!("{} ", bl.short_oid),
367 Style::default().fg(theme.warning),
368 ),
369 Span::styled(
370 format!("{:<12} ", author),
371 Style::default().fg(theme.accent),
372 ),
373 Span::styled(
374 format!("{:<8} ", rel),
375 Style::default().fg(theme.text_muted),
376 ),
377 Span::styled(
378 format!("{:>4} ", bl.line_number),
379 Style::default().fg(theme.text_muted),
380 ),
381 Span::styled(bl.content.clone(), Style::default().fg(theme.text_primary)),
382 ])
383 })
384 .collect();
385
386 let content_height = lines.len() as u16;
388 let visible_height = area.height.saturating_sub(2);
389 {
390 let tab = app.tab_mut();
391 if content_height > visible_height {
392 if tab.blame_scroll > content_height.saturating_sub(visible_height) {
393 tab.blame_scroll = content_height.saturating_sub(visible_height);
394 }
395 } else {
396 tab.blame_scroll = 0;
397 }
398 }
399
400 let scroll = app.tab().blame_scroll;
401 let paragraph = Paragraph::new(lines)
402 .block(block)
403 .wrap(Wrap { trim: false })
404 .scroll((scroll, 0));
405
406 frame.render_widget(paragraph, area);
407}
408
409fn render_diff_content(
411 app: &mut App,
412 frame: &mut Frame,
413 area: Rect,
414 border_color: ratatui::style::Color,
415) {
416 let theme = app.theme();
417 let is_multi = app.tab().selected_file_indices.len() > 1;
418
419 if is_multi {
420 let mut sorted_indices: Vec<usize> =
422 app.tab().selected_file_indices.iter().copied().collect();
423 sorted_indices.sort();
424
425 let title = format!(" Diff ({} files) ", sorted_indices.len());
426 let block = Block::default()
427 .title(title)
428 .borders(Borders::ALL)
429 .border_style(Style::default().fg(border_color))
430 .style(Style::default().bg(theme.bg));
431
432 let mut lines: Vec<Line> = Vec::new();
433 for idx in &sorted_indices {
434 let file_name = app
435 .tab()
436 .commit_files
437 .get(*idx)
438 .map(|f| f.display_path().to_string())
439 .unwrap_or_else(|| format!("file {}", idx));
440
441 lines.push(Line::from(Span::styled(
443 format!("══ {} ══", file_name),
444 Style::default()
445 .fg(theme.diff_hunk)
446 .add_modifier(Modifier::BOLD),
447 )));
448
449 if let Some(diff) = app.tab().commit_diffs.get(idx).cloned() {
450 for hunk in &diff.hunks {
451 for line in &hunk.lines {
452 lines.push(styled_diff_line(line, &theme));
453 }
454 }
455 } else {
456 lines.push(Line::from(Span::styled(
457 " Loading…",
458 Style::default().fg(theme.text_muted),
459 )));
460 }
461 lines.push(Line::default());
463 }
464
465 let content_height = lines.len() as u16;
467 let visible_height = area.height.saturating_sub(2);
468 {
469 let tab = app.tab_mut();
470 if content_height > visible_height {
471 if tab.diff_scroll > content_height.saturating_sub(visible_height) {
472 tab.diff_scroll = content_height.saturating_sub(visible_height);
473 }
474 } else {
475 tab.diff_scroll = 0;
476 }
477 }
478
479 let scroll = app.tab().diff_scroll;
480 let paragraph = Paragraph::new(lines)
481 .block(block)
482 .wrap(Wrap { trim: false })
483 .scroll((scroll, 0));
484
485 frame.render_widget(paragraph, area);
486 return;
487 }
488
489 let tab = app.tab_mut();
491
492 let title = match &tab.selected_diff {
493 Some(diff) => {
494 let name = diff.display_path();
495 if name.is_empty() {
496 " Diff ".to_string()
497 } else {
498 format!(" Diff: {} ", name)
499 }
500 }
501 None => " Diff ".to_string(),
502 };
503
504 let block = Block::default()
505 .title(title)
506 .borders(Borders::ALL)
507 .border_style(Style::default().fg(border_color))
508 .style(Style::default().bg(theme.bg));
509
510 match &tab.selected_diff {
511 None => {
512 let placeholder = Paragraph::new(Line::from(vec![Span::styled(
513 "Select a commit or file to view diff",
514 Style::default().fg(theme.text_muted),
515 )]))
516 .block(block)
517 .alignment(ratatui::layout::Alignment::Center);
518
519 frame.render_widget(placeholder, area);
520 }
521 Some(diff) => {
522 let mut lines: Vec<Line> = Vec::new();
523
524 for hunk in &diff.hunks {
525 for line in &hunk.lines {
526 lines.push(styled_diff_line(line, &theme));
527 }
528 }
529
530 let content_height = lines.len() as u16;
532 let visible_height = area.height.saturating_sub(2); if content_height > visible_height {
534 if tab.diff_scroll > content_height.saturating_sub(visible_height) {
535 tab.diff_scroll = content_height.saturating_sub(visible_height);
536 }
537 } else {
538 tab.diff_scroll = 0;
539 }
540
541 let paragraph = Paragraph::new(lines)
542 .block(block)
543 .wrap(Wrap { trim: false })
544 .scroll((tab.diff_scroll, 0));
545
546 frame.render_widget(paragraph, area);
547 }
548 }
549}