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