Skip to main content

void_graph/widget/
commit_list.rs

1//! Commit list widget with graph visualization.
2//!
3//! Adapted from [Serie](https://github.com/lusingander/serie) by lusingander.
4
5use ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::{Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, StatefulWidget, Widget},
11};
12
13use crate::color::{ColorTheme, GraphColorSet};
14use crate::graph::{calc_graph_owned, Edge, EdgeType, OwnedGraph};
15use crate::void_backend::{CommitCid, RefKind, VoidCommit, VoidHead, VoidRef};
16
17/// State for the commit list widget.
18#[derive(Debug)]
19pub struct CommitListState {
20    /// All commits to display
21    commits: Vec<VoidCommit>,
22    /// All refs for annotation
23    refs: Vec<VoidRef>,
24    /// Current HEAD
25    head: Option<VoidHead>,
26    /// Currently selected index (visual position from top of viewport)
27    selected: usize,
28    /// Scroll offset (first visible commit index)
29    offset: usize,
30    /// Full graph data computed from commits
31    graph: OwnedGraph,
32    /// Color set for graph lanes
33    color_set: GraphColorSet,
34}
35
36impl CommitListState {
37    /// Create a new commit list state.
38    pub fn new(commits: Vec<VoidCommit>, refs: Vec<VoidRef>, head: Option<VoidHead>) -> Self {
39        let graph = calc_graph_owned(&commits);
40        Self {
41            commits,
42            refs,
43            head,
44            selected: 0,
45            offset: 0,
46            graph,
47            color_set: GraphColorSet::default(),
48        }
49    }
50
51    /// Returns the total number of commits.
52    pub fn len(&self) -> usize {
53        self.commits.len()
54    }
55
56    /// Returns true if there are no commits.
57    pub fn is_empty(&self) -> bool {
58        self.commits.is_empty()
59    }
60
61    /// Get the currently selected commit.
62    pub fn selected_commit(&self) -> Option<&VoidCommit> {
63        let idx = self.offset + self.selected;
64        self.commits.get(idx)
65    }
66
67    /// Get the selected absolute index.
68    pub fn selected_index(&self) -> usize {
69        self.offset + self.selected
70    }
71
72    /// Move selection down by one.
73    pub fn select_next(&mut self, viewport_height: usize) {
74        let max_idx = self.commits.len().saturating_sub(1);
75        let current_abs = self.offset + self.selected;
76
77        if current_abs < max_idx {
78            if self.selected < viewport_height.saturating_sub(1) {
79                self.selected += 1;
80            } else {
81                self.offset += 1;
82            }
83        }
84    }
85
86    /// Move selection up by one.
87    pub fn select_prev(&mut self) {
88        if self.selected > 0 {
89            self.selected -= 1;
90        } else if self.offset > 0 {
91            self.offset -= 1;
92        }
93    }
94
95    /// Move selection down by half a page.
96    pub fn scroll_down_half(&mut self, viewport_height: usize) {
97        let half = viewport_height / 2;
98        for _ in 0..half {
99            self.select_next(viewport_height);
100        }
101    }
102
103    /// Move selection up by half a page.
104    pub fn scroll_up_half(&mut self, viewport_height: usize) {
105        let half = viewport_height / 2;
106        for _ in 0..half {
107            self.select_prev();
108        }
109    }
110
111    /// Move selection down by a full page.
112    pub fn scroll_down_page(&mut self, viewport_height: usize) {
113        for _ in 0..viewport_height {
114            self.select_next(viewport_height);
115        }
116    }
117
118    /// Move selection up by a full page.
119    pub fn scroll_up_page(&mut self, viewport_height: usize) {
120        for _ in 0..viewport_height {
121            self.select_prev();
122        }
123    }
124
125    /// Jump to the first commit.
126    pub fn select_first(&mut self) {
127        self.selected = 0;
128        self.offset = 0;
129    }
130
131    /// Jump to the last commit.
132    pub fn select_last(&mut self, viewport_height: usize) {
133        let total = self.commits.len();
134        if total <= viewport_height {
135            self.offset = 0;
136            self.selected = total.saturating_sub(1);
137        } else {
138            self.offset = total - viewport_height;
139            self.selected = viewport_height.saturating_sub(1);
140        }
141    }
142
143    /// Get refs pointing at a specific commit.
144    fn refs_at(&self, cid: &CommitCid) -> Vec<&VoidRef> {
145        self.refs
146            .iter()
147            .filter(|r| &r.target == cid)
148            .collect()
149    }
150
151    /// Check if a commit CID matches HEAD.
152    fn is_head(&self, cid: &CommitCid) -> bool {
153        match &self.head {
154            Some(VoidHead::Detached(head_cid)) => head_cid == cid,
155            Some(VoidHead::Branch(branch_name)) => self
156                .refs
157                .iter()
158                .any(|r| r.kind == RefKind::Branch && &r.name == branch_name && &r.target == cid),
159            None => false,
160        }
161    }
162}
163
164/// Map EdgeType to Unicode box-drawing character.
165fn edge_type_to_char(edge_type: EdgeType) -> char {
166    match edge_type {
167        EdgeType::Vertical => '│',
168        EdgeType::Horizontal => '─',
169        EdgeType::Up => '╵',
170        EdgeType::Down => '╷',
171        EdgeType::Left => '╴',
172        EdgeType::Right => '╶',
173        EdgeType::RightTop => '╮',
174        EdgeType::RightBottom => '╯',
175        EdgeType::LeftTop => '╭',
176        EdgeType::LeftBottom => '╰',
177    }
178}
179
180/// Render a graph row using the full edge data with per-lane coloring.
181///
182/// Returns a vector of (character, color) pairs for each cell.
183fn render_graph_row_full(
184    edges: &[Edge],
185    commit_pos_x: usize,
186    max_pos_x: usize,
187    color_set: &GraphColorSet,
188) -> Vec<(char, ratatui::style::Color)> {
189    use ratatui::style::Color;
190
191    let width = max_pos_x + 1;
192    let mut cells: Vec<(char, Option<usize>)> = vec![(' ', None); width];
193
194    // Place edges
195    for edge in edges {
196        if edge.pos_x < width {
197            let ch = edge_type_to_char(edge.edge_type);
198            cells[edge.pos_x] = (ch, Some(edge.associated_line_pos_x));
199        }
200    }
201
202    // Place commit node (overrides edges)
203    if commit_pos_x < width {
204        cells[commit_pos_x] = ('●', Some(commit_pos_x));
205    }
206
207    // Convert to colored output
208    cells
209        .into_iter()
210        .map(|(ch, lane)| {
211            let color = lane
212                .map(|l| color_set.get(l).to_ratatui_color())
213                .unwrap_or(Color::DarkGray);
214            (ch, color)
215        })
216        .collect()
217}
218
219/// The commit list widget.
220pub struct CommitList<'a> {
221    theme: &'a ColorTheme,
222}
223
224impl<'a> CommitList<'a> {
225    /// Create a new commit list widget.
226    pub fn new(theme: &'a ColorTheme) -> Self {
227        Self { theme }
228    }
229}
230
231impl<'a> StatefulWidget for CommitList<'a> {
232    type State = CommitListState;
233
234    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
235        // Render border
236        let block = Block::default()
237            .borders(Borders::ALL)
238            .title(" Commits ");
239        let inner = block.inner(area);
240        block.render(area, buf);
241
242        if state.is_empty() {
243            return;
244        }
245
246        let viewport_height = inner.height as usize;
247
248        // Calculate column widths - graph width depends on max_pos_x
249        // Each lane takes 2 chars: "│ " or "● " etc.
250        let graph_width = ((state.graph.max_pos_x + 1).max(1) * 2) as u16;
251        let cid_width = 9_u16; // 8 chars + space
252        let date_width = 11_u16; // "YYYY-MM-DD "
253        let remaining = inner
254            .width
255            .saturating_sub(graph_width + cid_width + date_width);
256        let subject_width = remaining;
257
258        // Render visible commits
259        for (i, idx) in (state.offset..).take(viewport_height).enumerate() {
260            if idx >= state.commits.len() {
261                break;
262            }
263
264            let commit = &state.commits[idx];
265            let is_selected = i == state.selected;
266            let y = inner.y + i as u16;
267
268            // Apply selection styling
269            let base_style = if is_selected {
270                Style::default()
271                    .fg(self.theme.list_selected_fg)
272                    .bg(self.theme.list_selected_bg)
273            } else {
274                Style::default()
275            };
276
277            // Get commit position from graph
278            let commit_pos_x = state
279                .graph
280                .commit_pos_map
281                .get(&commit.cid)
282                .map(|(x, _)| *x)
283                .unwrap_or(0);
284
285            // Get edges for this row
286            let edges = state.graph.edges.get(idx).map(|e| e.as_slice()).unwrap_or(&[]);
287
288            // Render graph with full edge types and colors
289            let colored_cells = render_graph_row_full(
290                edges,
291                commit_pos_x,
292                state.graph.max_pos_x,
293                &state.color_set,
294            );
295
296            // Draw each cell with its lane color
297            for (x_offset, (ch, color)) in colored_cells.iter().enumerate() {
298                let style = if is_selected {
299                    base_style
300                } else {
301                    Style::default().fg(*color)
302                };
303                let cell_x = inner.x + (x_offset * 2) as u16;
304                if cell_x < inner.x + graph_width {
305                    buf.set_string(cell_x, y, ch.to_string(), style);
306                    // Add space after each character
307                    if cell_x + 1 < inner.x + graph_width {
308                        buf.set_string(cell_x + 1, y, " ", Style::default());
309                    }
310                }
311            }
312
313            // Render CID
314            let cid_area = Rect::new(inner.x + graph_width, y, cid_width, 1);
315            buf.set_string(
316                cid_area.x,
317                cid_area.y,
318                format!("{:<8}", commit.cid.short()),
319                if is_selected {
320                    base_style
321                } else {
322                    Style::default().fg(self.theme.list_hash_fg)
323                },
324            );
325
326            // Subject with refs
327            let refs_at = state.refs_at(&commit.cid);
328            let is_head = state.is_head(&commit.cid);
329
330            let mut subject_parts: Vec<Span> = Vec::new();
331
332            // Add HEAD indicator
333            if is_head {
334                subject_parts.push(Span::styled(
335                    "HEAD ",
336                    Style::default()
337                        .fg(self.theme.list_head_fg)
338                        .add_modifier(Modifier::BOLD),
339                ));
340            }
341
342            // Add refs
343            if !refs_at.is_empty() {
344                subject_parts.push(Span::styled(
345                    "(",
346                    Style::default().fg(self.theme.list_ref_paren_fg),
347                ));
348
349                for (j, r) in refs_at.iter().enumerate() {
350                    if j > 0 {
351                        subject_parts.push(Span::styled(
352                            ", ",
353                            Style::default().fg(self.theme.list_ref_paren_fg),
354                        ));
355                    }
356                    let color = match r.kind {
357                        RefKind::Branch => self.theme.list_ref_branch_fg,
358                        RefKind::Tag => self.theme.list_ref_tag_fg,
359                    };
360                    subject_parts.push(Span::styled(&r.name, Style::default().fg(color)));
361                }
362
363                subject_parts.push(Span::styled(
364                    ") ",
365                    Style::default().fg(self.theme.list_ref_paren_fg),
366                ));
367            }
368
369            // Subject text (first line of message)
370            let subject = commit
371                .message
372                .lines()
373                .next()
374                .unwrap_or("")
375                .to_string();
376            subject_parts.push(Span::styled(
377                subject,
378                Style::default().fg(self.theme.list_subject_fg),
379            ));
380
381            // Truncate subject to fit
382            let subject_line = Line::from(subject_parts);
383
384            // Render subject (with refs)
385            let subj_area = Rect::new(
386                inner.x + graph_width + cid_width,
387                y,
388                subject_width.saturating_sub(date_width),
389                1,
390            );
391            let line_with_style = if is_selected {
392                Line::from(
393                    subject_line
394                        .spans
395                        .into_iter()
396                        .map(|s| s.style(base_style))
397                        .collect::<Vec<_>>(),
398                )
399            } else {
400                subject_line
401            };
402            buf.set_line(subj_area.x, subj_area.y, &line_with_style, subj_area.width);
403
404            // Render date at the end
405            let date_x = inner.x + inner.width.saturating_sub(date_width);
406            buf.set_string(
407                date_x,
408                y,
409                format_timestamp(commit.timestamp_ms),
410                if is_selected {
411                    base_style
412                } else {
413                    Style::default().fg(self.theme.list_date_fg)
414                },
415            );
416        }
417    }
418}
419
420/// Format a Unix timestamp (in milliseconds) as YYYY-MM-DD.
421fn format_timestamp(ts_ms: u64) -> String {
422    use std::time::{Duration, UNIX_EPOCH};
423    let d = UNIX_EPOCH + Duration::from_millis(ts_ms);
424    let datetime: chrono::DateTime<chrono::Utc> = d.into();
425    datetime.format("%Y-%m-%d").to_string()
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn make_commit(cid: &str, parents: Vec<&str>) -> VoidCommit {
433        VoidCommit {
434            cid: CommitCid(cid.to_string()),
435            parents: parents.into_iter().map(|p| CommitCid(p.to_string())).collect(),
436            message: String::new(),
437            timestamp_ms: 0,
438            is_signed: false,
439            signature_valid: None,
440            author: None,
441        }
442    }
443
444    #[test]
445    fn test_calc_graph_owned_empty() {
446        let graph = calc_graph_owned(&[]);
447        assert!(graph.commit_pos_map.is_empty());
448        assert!(graph.edges.is_empty());
449        assert_eq!(graph.max_pos_x, 0);
450    }
451
452    #[test]
453    fn test_calc_graph_owned_linear() {
454        let commits = vec![
455            make_commit("aaa", vec!["bbb"]),
456            make_commit("bbb", vec!["ccc"]),
457            make_commit("ccc", vec![]),
458        ];
459
460        let graph = calc_graph_owned(&commits);
461        // All should be in x position 0 for a linear history
462        assert_eq!(graph.commit_pos_map.len(), 3);
463        for commit in &commits {
464            let (x, _) = graph.commit_pos_map[&commit.cid];
465            assert_eq!(x, 0);
466        }
467    }
468
469    #[test]
470    fn test_edge_type_to_char() {
471        assert_eq!(edge_type_to_char(EdgeType::Vertical), '│');
472        assert_eq!(edge_type_to_char(EdgeType::Horizontal), '─');
473        assert_eq!(edge_type_to_char(EdgeType::Up), '╵');
474        assert_eq!(edge_type_to_char(EdgeType::Down), '╷');
475        assert_eq!(edge_type_to_char(EdgeType::Left), '╴');
476        assert_eq!(edge_type_to_char(EdgeType::Right), '╶');
477        assert_eq!(edge_type_to_char(EdgeType::RightTop), '╮');
478        assert_eq!(edge_type_to_char(EdgeType::RightBottom), '╯');
479        assert_eq!(edge_type_to_char(EdgeType::LeftTop), '╭');
480        assert_eq!(edge_type_to_char(EdgeType::LeftBottom), '╰');
481    }
482
483    #[test]
484    fn test_render_graph_row_full_single_commit() {
485        let color_set = GraphColorSet::default();
486        let edges = vec![Edge::new(EdgeType::Down, 0, 0)];
487        let cells = render_graph_row_full(&edges, 0, 0, &color_set);
488        // Should have commit node at position 0
489        assert_eq!(cells.len(), 1);
490        assert_eq!(cells[0].0, '●'); // Commit node overrides edge
491    }
492
493    #[test]
494    fn test_render_graph_row_full_with_vertical_line() {
495        let color_set = GraphColorSet::default();
496        let edges = vec![
497            Edge::new(EdgeType::Vertical, 1, 1),
498        ];
499        let cells = render_graph_row_full(&edges, 0, 1, &color_set);
500        assert_eq!(cells.len(), 2);
501        assert_eq!(cells[0].0, '●'); // Commit node at position 0
502        assert_eq!(cells[1].0, '│'); // Vertical line at position 1
503    }
504}