1use 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#[derive(Debug, Default)]
21pub struct CommitDetailState {
22 offset: usize,
24 viewport_height: usize,
26 content_height: usize,
28}
29
30impl CommitDetailState {
31 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn scroll_down(&mut self) {
38 if self.offset + self.viewport_height < self.content_height {
39 self.offset += 1;
40 }
41 }
42
43 pub fn scroll_up(&mut self) {
45 self.offset = self.offset.saturating_sub(1);
46 }
47
48 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 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 pub fn scroll_down_page(&mut self) {
66 for _ in 0..self.viewport_height {
67 self.scroll_down();
68 }
69 }
70
71 pub fn scroll_up_page(&mut self) {
73 for _ in 0..self.viewport_height {
74 self.scroll_up();
75 }
76 }
77
78 pub fn select_first(&mut self) {
80 self.offset = 0;
81 }
82
83 pub fn select_last(&mut self) {
85 self.offset = self.content_height.saturating_sub(self.viewport_height);
86 }
87
88 pub fn reset(&mut self) {
90 self.offset = 0;
91 }
92}
93
94pub struct CommitDetail<'a> {
96 commit: &'a VoidCommit,
98 refs: Vec<&'a VoidRef>,
100 theme: &'a ColorTheme,
102}
103
104impl<'a> CommitDetail<'a> {
105 pub fn new(commit: &'a VoidCommit, refs: Vec<&'a VoidRef>, theme: &'a ColorTheme) -> Self {
107 Self { commit, refs, theme }
108 }
109
110 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 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 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 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 if let Some(ref author) = self.commit.author {
154 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 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 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 lines.push(Line::from(""));
220
221 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 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
258fn 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}