1use ratatui::{
18 Frame,
19 layout::{Constraint, Layout, Rect},
20 style::{Color, Style},
21 text::{Line, Span},
22 widgets::Paragraph,
23};
24
25const HEADER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Green);
26const FOOTER_STYLE: Style = Style::new().fg(Color::White).bg(Color::Blue);
27
28use crate::app::{AppAction, AppState, KeyCommand};
29use crate::repo::GitRepo;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum FileStatus {
34 Added,
35 Modified,
36 Deleted,
37 Renamed,
38}
39
40pub fn handle_key(action: KeyCommand, app: &mut AppState) -> AppAction {
42 match action {
43 KeyCommand::MoveUp => {
44 app.scroll_detail_up();
45 AppAction::Handled
46 }
47 KeyCommand::MoveDown => {
48 app.scroll_detail_down();
49 AppAction::Handled
50 }
51 KeyCommand::PageUp => {
52 app.scroll_detail_page_up(app.detail_visible_height);
53 AppAction::Handled
54 }
55 KeyCommand::PageDown => {
56 app.scroll_detail_page_down(app.detail_visible_height);
57 AppAction::Handled
58 }
59 KeyCommand::ScrollLeft => {
60 app.scroll_detail_left();
61 AppAction::Handled
62 }
63 KeyCommand::ScrollRight => {
64 app.scroll_detail_right();
65 AppAction::Handled
66 }
67 KeyCommand::ToggleDetail | KeyCommand::Confirm => {
68 app.toggle_detail_view();
69 AppAction::Handled
70 }
71 KeyCommand::ShowHelp => {
72 app.toggle_help();
73 AppAction::Handled
74 }
75 KeyCommand::Update => AppAction::ReloadCommits,
76 KeyCommand::Quit => {
77 app.toggle_detail_view();
78 AppAction::Handled
79 }
80 KeyCommand::SeparatorLeft => {
81 app.separator_offset = app.separator_offset.saturating_sub(4);
82 AppAction::Handled
83 }
84 KeyCommand::SeparatorRight => {
85 app.separator_offset = app.separator_offset.saturating_add(4);
86 AppAction::Handled
87 }
88 _ => AppAction::Handled,
89 }
90}
91
92pub fn render(repo: &impl GitRepo, frame: &mut Frame, app: &mut AppState, area: Rect) {
96 let [header_area, content_area, footer_area] = Layout::vertical([
98 Constraint::Length(1),
99 Constraint::Min(0),
100 Constraint::Length(1),
101 ])
102 .areas(area);
103
104 let header_text = "Commit information";
106 let header = Paragraph::new(header_text).style(HEADER_STYLE);
107 frame.render_widget(header, header_area);
108
109 if app.commits.is_empty() {
111 let placeholder = Paragraph::new("No commits").style(Style::default().fg(Color::DarkGray));
112 frame.render_widget(placeholder, content_area);
113 } else {
114 let selected = &app.commits[app.selection_index];
115
116 let mut content = vec![
118 Line::from(""),
119 Line::from(vec![
120 Span::styled("Commit: ", Style::default().fg(Color::Yellow)),
121 Span::raw(&selected.oid),
122 ]),
123 Line::from(""),
124 ];
125
126 for line in selected.message.lines() {
128 content.push(Line::from(Span::styled(
129 line,
130 Style::default().fg(Color::White),
131 )));
132 }
133
134 content.push(Line::from(""));
135 if let (Some(author), Some(author_email)) = (&selected.author, &selected.author_email) {
136 content.push(Line::from(vec![
137 Span::styled("Author: ", Style::default().fg(Color::Yellow)),
138 Span::raw(format!("{} <{}>", author, author_email)),
139 ]));
140
141 let fmt = time::format_description::parse(
143 "[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour sign:mandatory][offset_minute]"
144 ).unwrap();
145
146 if let Some(author_date) = &selected.author_date {
147 let formatted = author_date
148 .format(&fmt)
149 .unwrap_or_else(|_| String::from("Invalid date"));
150 content.push(Line::from(vec![
151 Span::styled("Author Date: ", Style::default().fg(Color::Yellow)),
152 Span::raw(formatted),
153 ]));
154 }
155
156 if let (Some(committer), Some(committer_email)) =
157 (&selected.committer, &selected.committer_email)
158 {
159 content.push(Line::from(""));
160 content.push(Line::from(vec![
161 Span::styled("Committer: ", Style::default().fg(Color::Yellow)),
162 Span::raw(format!("{} <{}>", committer, committer_email)),
163 ]));
164 }
165
166 if let Some(commit_date) = &selected.commit_date {
167 let formatted = commit_date
168 .format(&fmt)
169 .unwrap_or_else(|_| String::from("Invalid date"));
170 content.push(Line::from(vec![
171 Span::styled("Commit Date: ", Style::default().fg(Color::Yellow)),
172 Span::raw(formatted),
173 ]));
174 }
175 }
176
177 let diff_opt = match selected.oid.as_str() {
179 "staged" => repo.staged_diff(),
180 "unstaged" => repo.unstaged_diff(),
181 oid => repo.commit_diff(oid).ok(),
182 };
183 if let Some(diff) = diff_opt {
184 content.push(Line::from(""));
185 content.push(Line::from(Span::styled(
186 "Changed Files:",
187 Style::default().fg(Color::Yellow),
188 )));
189 content.push(Line::from(""));
190
191 for file in &diff.files {
192 let (status, path) = get_file_status_and_path(file);
193 let status_str = format_file_status(status);
194 let status_color = get_status_color(status);
195
196 content.push(Line::from(vec![
197 Span::styled(
198 format!(" {} ", status_str),
199 Style::default().fg(status_color),
200 ),
201 Span::raw(path),
202 ]));
203 }
204
205 content.push(Line::from(""));
207 content.push(Line::from(Span::styled(
208 "Diff:",
209 Style::default().fg(Color::Yellow),
210 )));
211 content.push(Line::from(""));
212
213 for file in &diff.files {
214 let old_path = file
216 .old_path
217 .as_ref()
218 .map(|s| format!("a/{}", s))
219 .unwrap_or_else(|| "/dev/null".to_string());
220 let new_path = file
221 .new_path
222 .as_ref()
223 .map(|s| format!("b/{}", s))
224 .unwrap_or_else(|| "/dev/null".to_string());
225
226 content.push(Line::from(Span::styled(
227 format!("--- {}", old_path),
228 Style::default().fg(Color::White),
229 )));
230 content.push(Line::from(Span::styled(
231 format!("+++ {}", new_path),
232 Style::default().fg(Color::White),
233 )));
234
235 for hunk in &file.hunks {
237 let hunk_header = format!(
239 "@@ -{},{} +{},{} @@",
240 hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines
241 );
242 content.push(Line::from(Span::styled(
243 hunk_header,
244 Style::default().fg(Color::Cyan),
245 )));
246
247 for line in &hunk.lines {
249 use crate::DiffLineKind;
250
251 let (prefix, style) = match line.kind {
252 DiffLineKind::Addition => ("+", Style::default().fg(Color::Green)),
253 DiffLineKind::Deletion => ("-", Style::default().fg(Color::Red)),
254 DiffLineKind::Context => (" ", Style::default().fg(Color::White)),
255 };
256
257 let content_str = line.content.trim_end_matches(['\n', '\r']);
259 content.push(Line::from(Span::styled(
260 format!("{}{}", prefix, content_str),
261 style,
262 )));
263 }
264 }
265
266 content.push(Line::from(""));
267 }
268 }
269
270 let max_line_width = content.iter().map(|l| l.width()).max().unwrap_or(0);
272 let total_lines = content.len();
273 let v_scrollbar_width_tentative: u16 = if total_lines > content_area.height as usize {
274 1
275 } else {
276 0
277 };
278 let text_area_width = content_area
279 .width
280 .saturating_sub(v_scrollbar_width_tentative) as usize;
281 let max_h_scroll = max_line_width.saturating_sub(text_area_width);
282 let h_scrollbar_height: u16 = if max_h_scroll > 0 { 1 } else { 0 };
283
284 let visible_height = content_area.height.saturating_sub(h_scrollbar_height) as usize;
286 let max_scroll = total_lines.saturating_sub(visible_height);
287
288 app.max_detail_scroll = max_scroll;
290 app.detail_visible_height = visible_height;
291 app.max_detail_h_scroll = max_h_scroll;
292
293 let scroll_offset = app.detail_scroll_offset.min(max_scroll);
295 let h_scroll = app.detail_h_scroll_offset.min(max_h_scroll);
296
297 let v_scrollbar_width: u16 = if max_scroll > 0 { 1 } else { 0 };
299 let v_scrollbar_area = Rect {
300 x: content_area.x,
301 y: content_area.y,
302 width: v_scrollbar_width,
303 height: content_area.height.saturating_sub(h_scrollbar_height),
304 };
305 let text_area = Rect {
306 x: content_area.x + v_scrollbar_width,
307 y: content_area.y,
308 width: content_area.width.saturating_sub(v_scrollbar_width),
309 height: content_area.height.saturating_sub(h_scrollbar_height),
310 };
311 let h_scrollbar_area = Rect {
312 x: content_area.x + v_scrollbar_width,
313 y: content_area.y + content_area.height.saturating_sub(h_scrollbar_height),
314 width: content_area.width.saturating_sub(v_scrollbar_width),
315 height: h_scrollbar_height,
316 };
317
318 let paragraph = Paragraph::new(content).scroll((scroll_offset as u16, h_scroll as u16));
319 frame.render_widget(paragraph, text_area);
320
321 if max_scroll > 0 && visible_height > 0 {
322 render_scrollbar(
323 frame,
324 v_scrollbar_area,
325 scroll_offset,
326 total_lines,
327 visible_height,
328 );
329 }
330 if max_h_scroll > 0 && text_area_width > 0 {
331 render_h_scrollbar(
332 frame,
333 h_scrollbar_area,
334 h_scroll,
335 max_line_width,
336 text_area_width,
337 );
338 }
339 }
340
341 let footer = Paragraph::new("").style(FOOTER_STYLE);
343 frame.render_widget(footer, footer_area);
344}
345
346fn get_file_status_and_path(file: &crate::FileDiff) -> (FileStatus, String) {
348 use crate::DeltaStatus;
349
350 let status = match file.status {
351 DeltaStatus::Added => FileStatus::Added,
352 DeltaStatus::Deleted => FileStatus::Deleted,
353 DeltaStatus::Modified => FileStatus::Modified,
354 DeltaStatus::Renamed | DeltaStatus::Copied => FileStatus::Renamed,
355 DeltaStatus::Typechange => FileStatus::Modified,
356 _ => FileStatus::Modified,
357 };
358
359 let path = match (&file.old_path, &file.new_path) {
360 (_, Some(new))
361 if file.status != DeltaStatus::Renamed && file.status != DeltaStatus::Copied =>
362 {
363 new.clone()
364 }
365 (Some(old), Some(new)) => format!("{} → {}", old, new),
366 (Some(old), None) => old.clone(),
367 (None, Some(new)) => new.clone(),
368 (None, None) => String::from("<unknown>"),
369 };
370
371 (status, path)
372}
373
374fn format_file_status(status: FileStatus) -> &'static str {
376 match status {
377 FileStatus::Added => "A",
378 FileStatus::Modified => "M",
379 FileStatus::Deleted => "D",
380 FileStatus::Renamed => "R",
381 }
382}
383
384fn get_status_color(status: FileStatus) -> Color {
386 match status {
387 FileStatus::Added => Color::Green,
388 FileStatus::Modified => Color::Blue,
389 FileStatus::Deleted => Color::Red,
390 FileStatus::Renamed => Color::Cyan,
391 }
392}
393
394fn render_scrollbar(
396 frame: &mut Frame,
397 area: Rect,
398 scroll_offset: usize,
399 total_lines: usize,
400 visible_height: usize,
401) {
402 if area.height == 0 || total_lines == 0 {
403 return;
404 }
405
406 let scrollbar_height = area.height as usize;
407
408 let thumb_size = ((visible_height as f64 / total_lines as f64) * scrollbar_height as f64)
410 .ceil()
411 .max(1.0) as usize;
412 let thumb_size = thumb_size.min(scrollbar_height);
413
414 let scrollable_height = scrollbar_height.saturating_sub(thumb_size);
416 let thumb_position = if total_lines > visible_height {
417 ((scroll_offset as f64 / (total_lines - visible_height) as f64) * scrollable_height as f64)
418 .round() as usize
419 } else {
420 0
421 };
422
423 let mut scrollbar_lines = Vec::new();
425 for i in 0..scrollbar_height {
426 let char = if i >= thumb_position && i < thumb_position + thumb_size {
427 "█" } else {
429 "│" };
431 scrollbar_lines.push(Line::from(Span::styled(
432 char,
433 Style::default().fg(Color::DarkGray),
434 )));
435 }
436
437 let scrollbar = Paragraph::new(scrollbar_lines);
438 frame.render_widget(scrollbar, area);
439}
440
441fn render_h_scrollbar(
443 frame: &mut Frame,
444 area: Rect,
445 h_scroll: usize,
446 max_line_width: usize,
447 visible_width: usize,
448) {
449 if area.width == 0 || max_line_width == 0 {
450 return;
451 }
452
453 let track_width = area.width as usize;
454
455 let thumb_size = ((visible_width as f64 / max_line_width as f64) * track_width as f64)
457 .ceil()
458 .max(1.0) as usize;
459 let thumb_size = thumb_size.min(track_width);
460
461 let scrollable_track = track_width.saturating_sub(thumb_size);
463 let max_offset = max_line_width.saturating_sub(visible_width);
464 let thumb_position = if max_offset > 0 {
465 ((h_scroll as f64 / max_offset as f64) * scrollable_track as f64).round() as usize
466 } else {
467 0
468 };
469
470 let mut chars = String::new();
472 for i in 0..track_width {
473 if i >= thumb_position && i < thumb_position + thumb_size {
474 chars.push('█');
475 } else {
476 chars.push('─');
477 }
478 }
479
480 let scrollbar = Paragraph::new(Line::from(Span::styled(
481 chars,
482 Style::default().fg(Color::DarkGray),
483 )));
484 frame.render_widget(scrollbar, area);
485}