Skip to main content

void_graph/widget/
commit_detail.rs

1//! Commit detail widget.
2//!
3//! Displays detailed information about a single commit including CID,
4//! message, timestamp, parents, and signature status.
5//!
6//! Adapted from [Serie](https://github.com/lusingander/serie) by lusingander.
7
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, Borders, Paragraph, StatefulWidget, Widget, Wrap},
14};
15
16use crate::color::ColorTheme;
17use crate::void_backend::{RefKind, VoidCommit, VoidRef};
18
19/// State for the commit detail widget.
20#[derive(Debug, Default)]
21pub struct CommitDetailState {
22    /// Scroll offset for long content
23    offset: usize,
24    /// Viewport height (set during render)
25    viewport_height: usize,
26    /// Total content height (set during render)
27    content_height: usize,
28}
29
30impl CommitDetailState {
31    /// Create a new detail state.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Scroll down one line.
37    pub fn scroll_down(&mut self) {
38        if self.offset + self.viewport_height < self.content_height {
39            self.offset += 1;
40        }
41    }
42
43    /// Scroll up one line.
44    pub fn scroll_up(&mut self) {
45        self.offset = self.offset.saturating_sub(1);
46    }
47
48    /// Scroll down half a page.
49    pub fn scroll_down_half(&mut self) {
50        let half = self.viewport_height / 2;
51        for _ in 0..half {
52            self.scroll_down();
53        }
54    }
55
56    /// Scroll up half a page.
57    pub fn scroll_up_half(&mut self) {
58        let half = self.viewport_height / 2;
59        for _ in 0..half {
60            self.scroll_up();
61        }
62    }
63
64    /// Scroll down a full page.
65    pub fn scroll_down_page(&mut self) {
66        for _ in 0..self.viewport_height {
67            self.scroll_down();
68        }
69    }
70
71    /// Scroll up a full page.
72    pub fn scroll_up_page(&mut self) {
73        for _ in 0..self.viewport_height {
74            self.scroll_up();
75        }
76    }
77
78    /// Jump to the top.
79    pub fn select_first(&mut self) {
80        self.offset = 0;
81    }
82
83    /// Jump to the bottom.
84    pub fn select_last(&mut self) {
85        self.offset = self.content_height.saturating_sub(self.viewport_height);
86    }
87
88    /// Reset scroll position (call when changing commits).
89    pub fn reset(&mut self) {
90        self.offset = 0;
91    }
92}
93
94/// The commit detail widget.
95pub struct CommitDetail<'a> {
96    /// The commit to display
97    commit: &'a VoidCommit,
98    /// Refs pointing at this commit
99    refs: Vec<&'a VoidRef>,
100    /// Color theme
101    theme: &'a ColorTheme,
102}
103
104impl<'a> CommitDetail<'a> {
105    /// Create a new commit detail widget.
106    pub fn new(commit: &'a VoidCommit, refs: Vec<&'a VoidRef>, theme: &'a ColorTheme) -> Self {
107        Self { commit, refs, theme }
108    }
109
110    /// Build the content lines for display.
111    fn build_content(&self) -> Vec<Line<'a>> {
112        let mut lines = Vec::new();
113        let label_style = Style::default().fg(self.theme.detail_label_fg);
114
115        // CID
116        lines.push(Line::from(vec![
117            Span::styled("CID:       ", label_style),
118            Span::styled(
119                self.commit.cid.0.clone(),
120                Style::default().fg(self.theme.detail_hash_fg),
121            ),
122        ]));
123
124        // Parents
125        if self.commit.parents.is_empty() {
126            lines.push(Line::from(vec![
127                Span::styled("Parents:   ", label_style),
128                Span::styled("(none - root commit)", Style::default().fg(Color::DarkGray)),
129            ]));
130        } else {
131            for (i, parent) in self.commit.parents.iter().enumerate() {
132                let prefix = if i == 0 { "Parents:   " } else { "           " };
133                lines.push(Line::from(vec![
134                    Span::styled(prefix, label_style),
135                    Span::styled(
136                        parent.0.clone(),
137                        Style::default().fg(self.theme.detail_hash_fg),
138                    ),
139                ]));
140            }
141        }
142
143        // Date
144        lines.push(Line::from(vec![
145            Span::styled("Date:      ", label_style),
146            Span::styled(
147                format_timestamp_full(self.commit.timestamp_ms),
148                Style::default().fg(self.theme.detail_date_fg),
149            ),
150        ]));
151
152        // Author (signer identity)
153        if let Some(ref author) = self.commit.author {
154            // Show first 16 chars of the public key hex
155            let short_author = if author.len() > 16 {
156                format!("{}...", &author[..16])
157            } else {
158                author.clone()
159            };
160            lines.push(Line::from(vec![
161                Span::styled("Author:    ", label_style),
162                Span::styled(short_author, Style::default().fg(self.theme.detail_name_fg)),
163            ]));
164        }
165
166        // Signature status
167        let sig_line = if self.commit.is_signed {
168            match self.commit.signature_valid {
169                Some(true) => Line::from(vec![
170                    Span::styled("Signature: ", label_style),
171                    Span::styled(
172                        "Valid",
173                        Style::default()
174                            .fg(Color::Green)
175                            .add_modifier(Modifier::BOLD),
176                    ),
177                ]),
178                Some(false) => Line::from(vec![
179                    Span::styled("Signature: ", label_style),
180                    Span::styled(
181                        "Invalid",
182                        Style::default()
183                            .fg(Color::Red)
184                            .add_modifier(Modifier::BOLD),
185                    ),
186                ]),
187                None => Line::from(vec![
188                    Span::styled("Signature: ", label_style),
189                    Span::styled("Signed (unverified)", Style::default().fg(Color::Yellow)),
190                ]),
191            }
192        } else {
193            Line::from(vec![
194                Span::styled("Signature: ", label_style),
195                Span::styled("Unsigned", Style::default().fg(Color::DarkGray)),
196            ])
197        };
198        lines.push(sig_line);
199
200        // Refs
201        if !self.refs.is_empty() {
202            let mut ref_spans = vec![Span::styled("Refs:      ", label_style)];
203
204            for (i, r) in self.refs.iter().enumerate() {
205                if i > 0 {
206                    ref_spans.push(Span::raw(", "));
207                }
208                let color = match r.kind {
209                    RefKind::Branch => self.theme.detail_ref_branch_fg,
210                    RefKind::Tag => self.theme.detail_ref_tag_fg,
211                };
212                ref_spans.push(Span::styled(r.name.clone(), Style::default().fg(color)));
213            }
214
215            lines.push(Line::from(ref_spans));
216        }
217
218        // Empty line before message
219        lines.push(Line::from(""));
220
221        // Message
222        for line in self.commit.message.lines() {
223            lines.push(Line::from(line.to_string()));
224        }
225
226        lines
227    }
228}
229
230impl<'a> StatefulWidget for CommitDetail<'a> {
231    type State = CommitDetailState;
232
233    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
234        let block = Block::default()
235            .borders(Borders::ALL)
236            .title(" Commit Details ");
237        let inner = block.inner(area);
238        block.render(area, buf);
239
240        state.viewport_height = inner.height as usize;
241
242        let content = self.build_content();
243        state.content_height = content.len();
244
245        // Apply scroll offset
246        let visible_content: Vec<Line> = content
247            .into_iter()
248            .skip(state.offset)
249            .take(state.viewport_height)
250            .collect();
251
252        let paragraph = Paragraph::new(visible_content).wrap(Wrap { trim: false });
253
254        paragraph.render(inner, buf);
255    }
256}
257
258/// Format a Unix timestamp (in milliseconds) as full datetime.
259fn format_timestamp_full(ts_ms: u64) -> String {
260    use std::time::{Duration, UNIX_EPOCH};
261    let d = UNIX_EPOCH + Duration::from_millis(ts_ms);
262    let datetime: chrono::DateTime<chrono::Utc> = d.into();
263    datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_commit_detail_state_scroll() {
272        let mut state = CommitDetailState::new();
273        state.viewport_height = 10;
274        state.content_height = 20;
275
276        state.scroll_down();
277        assert_eq!(state.offset, 1);
278
279        state.scroll_up();
280        assert_eq!(state.offset, 0);
281
282        state.select_last();
283        assert_eq!(state.offset, 10);
284
285        state.select_first();
286        assert_eq!(state.offset, 0);
287    }
288}