Skip to main content

void_graph/widget/
ref_list.rs

1//! Reference list widget for branches and tags.
2//!
3//! Displays a tree-like view of repository references (branches and tags).
4//!
5//! Adapted from [Serie](https://github.com/lusingander/serie) by lusingander.
6
7use ratatui::{
8    buffer::Buffer,
9    layout::Rect,
10    style::{Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, StatefulWidget, Widget},
13};
14
15use crate::color::ColorTheme;
16use crate::void_backend::{RefKind, VoidHead, VoidRef};
17
18/// A node in the ref tree.
19#[derive(Debug, Clone)]
20enum RefNode {
21    /// Category header (Branches, Tags)
22    Category { name: String, expanded: bool },
23    /// Leaf ref (branch or tag)
24    Leaf { ref_: VoidRef },
25}
26
27/// State for the ref list widget.
28#[derive(Debug)]
29pub struct RefListState {
30    /// All refs
31    refs: Vec<VoidRef>,
32    /// Current HEAD
33    head: Option<VoidHead>,
34    /// Flattened visible nodes
35    nodes: Vec<RefNode>,
36    /// Selected node index
37    selected: usize,
38    /// Scroll offset
39    offset: usize,
40    /// Categories expanded state
41    branches_expanded: bool,
42    tags_expanded: bool,
43}
44
45impl RefListState {
46    /// Create a new ref list state.
47    pub fn new(refs: Vec<VoidRef>, head: Option<VoidHead>) -> Self {
48        let branches_expanded = true;
49        let tags_expanded = true;
50
51        let mut state = Self {
52            refs,
53            head,
54            nodes: Vec::new(),
55            selected: 0,
56            offset: 0,
57            branches_expanded,
58            tags_expanded,
59        };
60        state.rebuild_nodes();
61        state
62    }
63
64    /// Rebuild the flattened node list based on expansion state.
65    fn rebuild_nodes(&mut self) {
66        self.nodes.clear();
67
68        // Branches category
69        let branches: Vec<_> = self
70            .refs
71            .iter()
72            .filter(|r| r.kind == RefKind::Branch)
73            .cloned()
74            .collect();
75
76        if !branches.is_empty() {
77            self.nodes.push(RefNode::Category {
78                name: "Branches".to_string(),
79                expanded: self.branches_expanded,
80            });
81
82            if self.branches_expanded {
83                for branch in branches {
84                    self.nodes.push(RefNode::Leaf { ref_: branch });
85                }
86            }
87        }
88
89        // Tags category
90        let tags: Vec<_> = self
91            .refs
92            .iter()
93            .filter(|r| r.kind == RefKind::Tag)
94            .cloned()
95            .collect();
96
97        if !tags.is_empty() {
98            self.nodes.push(RefNode::Category {
99                name: "Tags".to_string(),
100                expanded: self.tags_expanded,
101            });
102
103            if self.tags_expanded {
104                for tag in tags {
105                    self.nodes.push(RefNode::Leaf { ref_: tag });
106                }
107            }
108        }
109    }
110
111    /// Returns true if there are no refs.
112    pub fn is_empty(&self) -> bool {
113        self.refs.is_empty()
114    }
115
116    /// Get the currently selected ref name, if a leaf is selected.
117    pub fn selected_ref_name(&self) -> Option<&str> {
118        self.nodes.get(self.selected).and_then(|node| match node {
119            RefNode::Leaf { ref_ } => Some(ref_.name.as_str()),
120            RefNode::Category { .. } => None,
121        })
122    }
123
124    /// Get the selected ref if a leaf is selected.
125    pub fn selected_ref(&self) -> Option<&VoidRef> {
126        self.nodes.get(self.selected).and_then(|node| match node {
127            RefNode::Leaf { ref_ } => Some(ref_),
128            RefNode::Category { .. } => None,
129        })
130    }
131
132    /// Move selection down.
133    pub fn select_next(&mut self, viewport_height: usize) {
134        if self.selected < self.nodes.len().saturating_sub(1) {
135            self.selected += 1;
136            // Adjust scroll if needed
137            if self.selected >= self.offset + viewport_height {
138                self.offset = self.selected - viewport_height + 1;
139            }
140        }
141    }
142
143    /// Move selection up.
144    pub fn select_prev(&mut self) {
145        if self.selected > 0 {
146            self.selected -= 1;
147            // Adjust scroll if needed
148            if self.selected < self.offset {
149                self.offset = self.selected;
150            }
151        }
152    }
153
154    /// Toggle expansion of selected category.
155    pub fn toggle_selected(&mut self) {
156        if let Some(RefNode::Category { name, expanded }) = self.nodes.get(self.selected).cloned() {
157            if name == "Branches" {
158                self.branches_expanded = !expanded;
159            } else if name == "Tags" {
160                self.tags_expanded = !expanded;
161            }
162            self.rebuild_nodes();
163        }
164    }
165
166    /// Jump to first node.
167    pub fn select_first(&mut self) {
168        self.selected = 0;
169        self.offset = 0;
170    }
171
172    /// Jump to last node.
173    pub fn select_last(&mut self, viewport_height: usize) {
174        self.selected = self.nodes.len().saturating_sub(1);
175        if self.nodes.len() > viewport_height {
176            self.offset = self.nodes.len() - viewport_height;
177        }
178    }
179
180    /// Check if a ref is the current HEAD branch.
181    fn is_head_branch(&self, name: &str) -> bool {
182        matches!(&self.head, Some(VoidHead::Branch(b)) if b == name)
183    }
184}
185
186/// The ref list widget.
187pub struct RefList<'a> {
188    theme: &'a ColorTheme,
189}
190
191impl<'a> RefList<'a> {
192    /// Create a new ref list widget.
193    pub fn new(theme: &'a ColorTheme) -> Self {
194        Self { theme }
195    }
196}
197
198impl<'a> StatefulWidget for RefList<'a> {
199    type State = RefListState;
200
201    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
202        let block = Block::default()
203            .borders(Borders::ALL)
204            .title(" Refs ");
205        let inner = block.inner(area);
206        block.render(area, buf);
207
208        if state.nodes.is_empty() {
209            return;
210        }
211
212        let viewport_height = inner.height as usize;
213
214        for (i, idx) in (state.offset..).take(viewport_height).enumerate() {
215            if idx >= state.nodes.len() {
216                break;
217            }
218
219            let node = &state.nodes[idx];
220            let is_selected = idx == state.selected;
221            let y = inner.y + i as u16;
222
223            let base_style = if is_selected {
224                Style::default()
225                    .fg(self.theme.ref_selected_fg)
226                    .bg(self.theme.ref_selected_bg)
227            } else {
228                Style::default()
229            };
230
231            let line = match node {
232                RefNode::Category { name, expanded } => {
233                    let arrow = if *expanded { " " } else { " " };
234                    Line::from(vec![
235                        Span::styled(arrow, base_style.add_modifier(Modifier::BOLD)),
236                        Span::styled(name.clone(), base_style.add_modifier(Modifier::BOLD)),
237                    ])
238                }
239                RefNode::Leaf { ref_ } => {
240                    let indent = "  ";
241                    let color = match ref_.kind {
242                        RefKind::Branch => {
243                            if state.is_head_branch(&ref_.name) {
244                                self.theme.list_head_fg
245                            } else {
246                                self.theme.list_ref_branch_fg
247                            }
248                        }
249                        RefKind::Tag => self.theme.list_ref_tag_fg,
250                    };
251
252                    let name_style = if is_selected {
253                        base_style
254                    } else {
255                        Style::default().fg(color)
256                    };
257
258                    let mut spans = vec![Span::styled(indent, base_style)];
259
260                    // Add HEAD marker if this is the current branch
261                    if state.is_head_branch(&ref_.name) {
262                        spans.push(Span::styled(
263                            "* ",
264                            if is_selected {
265                                base_style.add_modifier(Modifier::BOLD)
266                            } else {
267                                Style::default()
268                                    .fg(self.theme.list_head_fg)
269                                    .add_modifier(Modifier::BOLD)
270                            },
271                        ));
272                    }
273
274                    spans.push(Span::styled(ref_.name.clone(), name_style));
275                    Line::from(spans)
276                }
277            };
278
279            buf.set_line(inner.x, y, &line, inner.width);
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::void_backend::CommitCid;
288
289    fn make_refs() -> Vec<VoidRef> {
290        vec![
291            VoidRef {
292                name: "main".to_string(),
293                kind: RefKind::Branch,
294                target: CommitCid("aaa".to_string()),
295            },
296            VoidRef {
297                name: "feature".to_string(),
298                kind: RefKind::Branch,
299                target: CommitCid("bbb".to_string()),
300            },
301            VoidRef {
302                name: "v1.0".to_string(),
303                kind: RefKind::Tag,
304                target: CommitCid("ccc".to_string()),
305            },
306        ]
307    }
308
309    #[test]
310    fn test_ref_list_state_new() {
311        let refs = make_refs();
312        let state = RefListState::new(refs, Some(VoidHead::Branch("main".to_string())));
313
314        // Should have: Branches category, 2 branches, Tags category, 1 tag = 5 nodes
315        assert_eq!(state.nodes.len(), 5);
316    }
317
318    #[test]
319    fn test_ref_list_toggle_category() {
320        let refs = make_refs();
321        let mut state = RefListState::new(refs, None);
322
323        // Initially all expanded: 5 nodes
324        assert_eq!(state.nodes.len(), 5);
325
326        // Select Branches category and toggle
327        state.selected = 0;
328        state.toggle_selected();
329
330        // Now branches collapsed: Branches header + Tags header + 1 tag = 3 nodes
331        assert_eq!(state.nodes.len(), 3);
332    }
333
334    #[test]
335    fn test_selected_ref() {
336        let refs = make_refs();
337        let state = RefListState::new(refs, None);
338
339        // Index 0 is Branches category - should return None
340        assert!(state.selected_ref_name().is_none());
341    }
342}