Skip to main content

git_tailor/views/
commit_detail.rs

1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Commit detail view — metadata and diff
16
17use 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/// File status indicator for changed files.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum FileStatus {
34    Added,
35    Modified,
36    Deleted,
37    Renamed,
38}
39
40/// Handle an action while in CommitDetail mode.
41pub 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
92/// Render the commit detail view.
93///
94/// Displays commit metadata and diff in the right panel.
95pub fn render(repo: &impl GitRepo, frame: &mut Frame, app: &mut AppState, area: Rect) {
96    // Split area into header, content, and footer
97    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    // Render header
105    let header_text = "Commit information";
106    let header = Paragraph::new(header_text).style(HEADER_STYLE);
107    frame.render_widget(header, header_area);
108
109    // Render content
110    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        // Build metadata lines
117        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        // Add full message (split into lines)
127        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            // Format dates as "YYYY-MM-DD HH:MM:SS ±HHMM"
142            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        // Add file list with status indicators
178        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            // Add complete diff rendering
206            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                // File headers (unified diff format)
215                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                // Render each hunk
236                for hunk in &file.hunks {
237                    // Hunk header
238                    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                    // Render each line
248                    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                        // Remove trailing newline (including Windows-style \r\n)
258                        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        // Compute max line width for horizontal scrollbar (pass 1: tentative v-scrollbar width)
271        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        // Final visible height (accounting for horizontal scrollbar row)
285        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        // Update scroll state in app for proper bounds and page scrolling
289        app.max_detail_scroll = max_scroll;
290        app.detail_visible_height = visible_height;
291        app.max_detail_h_scroll = max_h_scroll;
292
293        // Clamp scroll offsets to valid range
294        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        // Layout: v-scrollbar strip on the left, h-scrollbar strip at the bottom
298        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    // Render footer
342    let footer = Paragraph::new("").style(FOOTER_STYLE);
343    frame.render_widget(footer, footer_area);
344}
345
346/// Determine file status and display path from a FileDiff.
347fn 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
374/// Format file status as a single character indicator.
375fn 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
384/// Get color for file status indicator.
385fn 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
394/// Render a vertical scrollbar indicating scroll position.
395fn 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    // Calculate thumb size (proportional to visible content)
409    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    // Calculate thumb position
415    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    // Build scrollbar lines
424    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            "█" // Solid block for thumb
428        } else {
429            "│" // Light vertical line for track
430        };
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
441/// Render a horizontal scrollbar indicating horizontal scroll position.
442fn 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    // Calculate thumb size (proportional to visible content)
456    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    // Calculate thumb position
462    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    // Build scrollbar as a single line of characters
471    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}