sl_up/
graph.rs

1//! A SaplingSCM Smartlog output graph (`sl ssl`) is made of two types of items:
2//! 1. Commits: These usually comprise of 2 lines. The first line holds the commit hash, date, author and PR status. The second line holds the commit message. Commits can be local or remote.
3//! 2. Glyphs: These are the graph elements that connect commits to each other.
4//!
5//! In our UI, we want to render the exact output of the smartlog. The interactivity we add to the graph makes only commits selectable and actionable.
6//! We render the glyphs as well, but they are not made selectable.
7//!
8use enum_dispatch::enum_dispatch;
9
10const LOCAL_COMMIT_HASH_COLOR: &str = "\u{1b}[0;93;1m";
11const REMOTE_COMMIT_HASH_COLOR: &str = "\u{1b}[0;33m";
12
13/// A graph item representing a commit in the smartlog output. It can be selected and deselected.
14#[derive(Debug)]
15pub struct Commit {
16    lines: Vec<Vec<String>>,
17    pub selected: bool,
18}
19impl Commit {
20    pub fn new(parsed_lines: Vec<Vec<String>>, selected: bool) -> Self {
21        Self {
22            lines: parsed_lines,
23            selected,
24        }
25    }
26
27    /// Get the hash of this commit which can be used for operations such as `sl goto <hash>`
28    /// ```
29    ///  # use sl_up::graph::Commit;
30    ///  let commit_lines = vec![
31    ///      vec!["  @  ", "\u{1b}[0;35m", "\u{1b}[0;93;1m", "1cee5d55e", "\u{1b}[0m", "\u{1b}[0;35m", "  Dec 08 at 09:46  royrothenberg  ", "\u{1b}[0;36m", "#780 Closed", "\u{1b}[0m", "\u{1b}[0;35m", " ", "\u{1b}[0;31m", "✗", "\u{1b}[0m"],
32    ///      vec!["  │  ", "\u{1b}[0;35m", "[pr body update] update stack list without overwriting PR title and body", "\u{1b}[0m"],
33    ///  ].iter().map(|x| x.iter().map(|x| x.to_string()).collect()).collect();
34    ///  let commit = Commit::new(commit_lines, true);
35    ///  assert_eq!(commit.hash().unwrap(), "1cee5d55e");
36    /// ```
37    ///
38    pub fn hash(&self) -> Option<&str> {
39        let first_line = self.parsed_lines().first().unwrap();
40
41        let mut commit_hash_index = 0;
42        for (index, text) in first_line.iter().enumerate() {
43            if (text == REMOTE_COMMIT_HASH_COLOR) | (text == LOCAL_COMMIT_HASH_COLOR) {
44                commit_hash_index = index + 1;
45                break;
46            }
47        }
48        Some(&first_line[commit_hash_index])
49    }
50
51    pub fn select(&mut self) {
52        if self.selected {
53            return;
54        }
55
56        self.selected = true;
57        for line in self.lines.iter_mut() {
58            Self::add_selection_color(line);
59        }
60    }
61
62    pub fn deselect(&mut self) {
63        if !self.selected {
64            return;
65        }
66
67        self.selected = false;
68        for line in self.lines.iter_mut() {
69            Self::remove_selection_color(line);
70        }
71    }
72
73    fn add_selection_color(line: &mut Vec<String>) {
74        if line.len() > 4 {
75            line.insert(4, Self::selection_formatter());
76        } else if line.len() > 1 {
77            line.insert(1, Self::selection_formatter());
78        } else {
79            let text = line.pop().unwrap();
80            for block in text.splitn(2, ' ') {
81                line.push(block.to_string());
82            }
83            // restore the space we removed with the split above
84            line.insert(1, " ".to_string());
85            line.insert(1, Self::selection_formatter());
86        }
87        line.push(Self::stop_formatter());
88    }
89
90    fn remove_selection_color(line: &mut Vec<String>) {
91        line.retain(|text| !text.contains(&Self::selection_formatter()));
92    }
93
94    fn selection_formatter() -> String {
95        "\u{1b}[0;35m".to_string()
96    }
97
98    fn stop_formatter() -> String {
99        "\u{1b}[0m".to_string()
100    }
101}
102
103/// A graph item representing a glyph in the smartlog output.
104/// Usually, this is part of the graph drawing connecting commits together.
105#[derive(Debug)]
106pub struct Glyph {
107    lines: Vec<Vec<String>>,
108}
109impl Glyph {
110    pub fn new(parsed_lines: Vec<Vec<String>>) -> Self {
111        Self {
112            lines: parsed_lines,
113        }
114    }
115}
116
117/// A trait for graph items (using enum_dispatch).
118#[enum_dispatch(ItemType)]
119pub trait Item {
120    fn parsed_lines(&self) -> &Vec<Vec<String>>;
121    fn add_parsed_line(&mut self, parsed_line: Vec<String>);
122    fn to_string_vec(&self) -> Vec<String> {
123        self.parsed_lines()
124            .iter()
125            .map(|line| line.join(""))
126            .collect()
127    }
128}
129
130impl Item for Commit {
131    fn parsed_lines(&self) -> &Vec<Vec<String>> {
132        &self.lines
133    }
134
135    fn add_parsed_line(&mut self, parsed_line: Vec<String>) {
136        self.lines.push(parsed_line);
137    }
138}
139
140impl Item for Glyph {
141    fn parsed_lines(&self) -> &Vec<Vec<String>> {
142        &self.lines
143    }
144
145    fn add_parsed_line(&mut self, parsed_line: Vec<String>) {
146        self.lines.push(parsed_line);
147    }
148}
149
150/// An enum of graph item types (using enum_dispatch).
151#[enum_dispatch]
152#[derive(Debug)]
153pub enum ItemType {
154    Commit,
155    Glyph,
156}
157
158#[cfg(test)]
159mod tests {
160
161    use crate::parser::SmartLogParser;
162
163    use super::*;
164
165    const RAW_LINES: [&str; 15] = [
166        "  @  \u{1b}[0;35m\u{1b}[0;93;1m1cee5d55e\u{1b}[0m\u{1b}[0;35m  Dec 08 at 09:46  royrothenberg  \u{1b}[0;36m#780 Closed\u{1b}[0m\u{1b}[0;35m \u{1b}[0;31m✗\u{1b}[0m",
167        "  │  \u{1b}[0;35m[pr body update] update stack list without overwriting PR title and body\u{1b}[0m",
168        "  │",
169        "  o  \u{1b}[0;93;1mc3bd9e5fa\u{1b}[0m  Dec 08 at 09:46  royrothenberg  \u{1b}[0;38;2;141;148;158m#779 Unreviewed\u{1b}[0m \u{1b}[0;31m✗\u{1b}[0m",
170        "╭─╯  [pr body update] fix reviewstack option breaking stack list detection",
171        "│",
172        "o  \u{1b}[0;33mba27d4d13\u{1b}[0m  Dec 07 at 22:20  \u{1b}[0;32mremote/main\u{1b}[0m",
173        "╷",
174        "╷ o  \u{1b}[0;93;1m2f85065e7\u{1b}[0m  Nov 28 at 11:49  royrothenberg  \u{1b}[0;36m#781 Closed\u{1b}[0m \u{1b}[0;32m✓\u{1b}[0m",
175        "╭─╯  [isl] increase width of diff window in split stack edit panel",
176        "│",
177        "o  \u{1b}[0;33m0e069ab09\u{1b}[0m  Nov 21 at 13:16",
178        "│",
179        "~",
180        "",
181    ];
182
183    #[test]
184    fn test_commit() {
185        let mut commit = Commit::new(vec![vec!["a".to_string()]], true);
186        commit.add_parsed_line(vec!["b".to_string()]);
187        assert_eq!(
188            commit.lines,
189            vec![vec!["a".to_string()], vec!["b".to_string()]]
190        );
191        assert!(commit.selected);
192        commit.selected = false;
193        assert!(!commit.selected);
194    }
195
196    #[test]
197    fn test_glyph() {
198        let mut glyph = Glyph::new(vec![vec!["a".to_string()]]);
199        glyph.add_parsed_line(vec!["b".to_string()]);
200        assert_eq!(
201            glyph.parsed_lines(),
202            &vec![vec!["a".to_string()], vec!["b".to_string()]]
203        );
204    }
205
206    #[test]
207    fn test_item_types() {
208        let mut items: Vec<ItemType> = vec![
209            Commit::new(vec![vec!["a".to_string()]], true).into(),
210            Glyph::new(vec![vec!["a".to_string()]]).into(),
211        ];
212
213        for item in items.iter_mut() {
214            item.add_parsed_line(vec!["b".to_string()]);
215
216            match item {
217                ItemType::Commit(commit) => {
218                    assert_eq!(
219                        commit.parsed_lines(),
220                        &vec![vec!["a".to_string()], vec!["b".to_string()]]
221                    );
222                    assert!(commit.selected);
223                    commit.selected = false;
224                    assert!(!commit.selected);
225                }
226                ItemType::Glyph(glyph) => {
227                    assert_eq!(
228                        glyph.parsed_lines(),
229                        &vec![vec!["a".to_string()], vec!["b".to_string()]]
230                    );
231                }
232            }
233        }
234    }
235
236    #[test]
237    fn test_select() {
238        let graph_items = &mut SmartLogParser::parse(&raw_lines()).unwrap();
239
240        let commit = &mut graph_items[2];
241        match commit {
242            ItemType::Commit(commit) => {
243                assert!(!commit.selected);
244                assert_eq!(
245                    commit.parsed_lines()[0][4],
246                    "  Dec 08 at 09:46  royrothenberg  "
247                );
248                assert_eq!(
249                    commit.parsed_lines()[1][1],
250                    "  [pr body update] fix reviewstack option breaking stack list detection"
251                );
252                commit.select();
253                assert!(commit.selected);
254                assert_eq!(
255                    commit.parsed_lines()[0][4],
256                    "\u{1b}[0;35m", // This item was inserted by select()
257                );
258                assert_eq!(
259                    commit.parsed_lines()[1][1],
260                    "\u{1b}[0;35m", // This item was inserted by select()
261                );
262            }
263            _ => panic!("Expected GraphCommit"),
264        }
265    }
266
267    #[test]
268    fn test_deselect() {
269        let graph_items = &mut SmartLogParser::parse(&raw_lines()).unwrap();
270
271        let commit = &mut graph_items[0];
272        match commit {
273            ItemType::Commit(commit) => {
274                assert!(commit.selected);
275                assert!(commit.parsed_lines()[0].contains(&"\u{1b}[0;35m".to_string()));
276                assert!(commit.parsed_lines()[1].contains(&"\u{1b}[0;35m".to_string()));
277                commit.deselect();
278                assert!(!commit.selected);
279                assert!(!commit.parsed_lines()[0].contains(&"\u{1b}[0;35m".to_string()));
280                assert!(!commit.parsed_lines()[1].contains(&"\u{1b}[0;35m".to_string()));
281            }
282            _ => panic!("Expected GraphCommit"),
283        }
284    }
285
286    #[test]
287    fn test_hash() {
288        let graph_items = &mut SmartLogParser::parse(&raw_lines()).unwrap();
289
290        let local_commit = &mut graph_items[0];
291        match local_commit {
292            ItemType::Commit(commit) => {
293                assert_eq!(commit.hash().unwrap(), "1cee5d55e");
294            }
295            _ => panic!("Expected GraphCommit"),
296        }
297
298        let remote_commit = &mut graph_items[4];
299        match remote_commit {
300            ItemType::Commit(commit) => {
301                assert_eq!(commit.hash().unwrap(), "ba27d4d13");
302            }
303            _ => panic!("Expected GraphCommit"),
304        }
305    }
306
307    fn raw_lines() -> Vec<String> {
308        RAW_LINES.iter().map(|x| x.to_string()).collect()
309    }
310}