use crate::stateful::StatefulList;
use crossterm::event::KeyCode;
use ratatui::{prelude::*, widgets::*};
use std::collections::{HashMap, HashSet};
use std::ops::Range;
enum ParseState {
Commit,
Author,
Date,
Message,
Diff,
}
fn parse_block(line: &str) -> (usize, usize, usize, usize) {
let tokens: Vec<_> = line.split(" ").collect();
let begin = tokens[1];
let end = tokens[2];
let btokens: Vec<_> = begin[1..].split(",").collect();
let begin_from = btokens[0].parse().unwrap();
let begin_len: usize = btokens[1].parse().unwrap_or(0);
let etokens: Vec<_> = end[1..].split(",").collect();
let end_from = etokens[0].parse().unwrap();
let end_len: usize = etokens[1].parse().unwrap_or(0);
(
begin_from,
begin_from + begin_len,
end_from,
end_from + end_len,
)
}
#[derive(Debug)]
struct LineState<'a> {
blocks: Vec<(Range<usize>, Range<usize>)>,
oldblocks: Vec<(Range<usize>, Range<usize>)>,
commit: &'a str,
}
impl<'a> LineState<'a> {
fn new() -> Self {
Self {
blocks: vec![],
oldblocks: vec![],
commit: "",
}
}
fn fuse_blocks(&self) -> Vec<(Range<usize>, Range<usize>)> {
if self.oldblocks.is_empty() {
self.blocks.clone()
} else {
let mut new_blocks = vec![];
for (before, after) in &self.blocks {
let mut offset: i64 = 0;
for (before_original, original) in &self.oldblocks {
if after.end < before_original.start {
offset += (original.end - original.start) as i64
- (before_original.end - before_original.start) as i64;
continue;
} else if after.start > before.end {
break;
} else {
let original_start = if after.start > before_original.start {
original
.start
.saturating_sub(after.start - before_original.start)
} else {
before.start + before_original.start - after.start
};
let original_end = if after.end > before_original.end {
original.end + after.end - before_original.end
} else {
before.end - (before_original.end - after.end)
};
let original_start: usize =
(original_start as i64 + offset).try_into().unwrap();
let original_end: usize =
(original_end as i64 + offset).try_into().unwrap();
if original_end == original_start {
continue;
} else {
new_blocks.push((before.clone(), original_start..original_end));
break;
}
}
}
}
new_blocks
}
}
fn update_map<'b>(&mut self, map: &'b mut HashMap<usize, HashSet<&'a str>>) {
let blocks = self.fuse_blocks();
for (_before, after) in &blocks {
for current_line in after.clone() {
map.entry(current_line)
.or_insert(HashSet::new())
.insert(self.commit);
}
}
self.oldblocks = blocks;
self.blocks.clear();
}
}
fn update_diff_state<'a>(
line: &'a str,
state: &mut LineState<'a>,
) {
if line.starts_with("@@") {
let (end_from, end_to, begin_from, begin_to) = parse_block(line);
state.blocks.push((end_from..end_to, begin_from..begin_to));
}
}
fn parse(output: &str) -> HashMap<usize, HashSet<&str>> {
let mut state = ParseState::Commit;
let mut map = HashMap::new();
let mut line_state = LineState::new();
for line in output.lines() {
match state {
ParseState::Commit => {
assert!(line.starts_with("commit "));
line_state.commit = &line["commit ".len()..];
state = ParseState::Author;
}
ParseState::Author => {
assert!(line.starts_with("Author: "));
state = ParseState::Date;
}
ParseState::Date => {
assert!(line.starts_with("Date: "));
state = ParseState::Message;
}
ParseState::Message => {
if line.starts_with("diff ") {
state = ParseState::Diff;
}
}
ParseState::Diff => {
if line.is_empty() {
state = ParseState::Commit;
line_state.update_map(&mut map);
} else {
update_diff_state(line, &mut line_state);
}
}
}
}
if let ParseState::Diff = state {
line_state.update_map(&mut map);
}
map
}
pub struct Viewer<'a> {
file: &'a str,
items: StatefulList<(&'a str, usize)>,
}
impl<'a> Viewer<'a> {
pub fn new(file: &'a str, content: &'a str, gitlog: &'a str) -> Self {
let lines: Vec<&str> = content.lines().collect();
let commits = parse(&gitlog);
let lines = lines
.into_iter()
.enumerate()
.map(|(i, line)| {
(
line,
commits.get(&i).map(|commits| commits.len()).unwrap_or(0),
)
})
.collect();
let items = StatefulList::with_items(lines);
Self { file, items }
}
pub fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Up => self.items.previous(),
KeyCode::Down => self.items.next(),
KeyCode::PageUp => self.items.previous_nth(20),
KeyCode::PageDown => self.items.next_nth(20),
_ => {}
}
}
pub fn render<B: Backend>(&mut self, f: &mut Frame<B>) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Min(10),
Constraint::Length(4),
Constraint::Length(4),
]
.as_ref(),
)
.split(f.size());
let mut max_value = 0;
let items: Vec<ListItem> = self
.items
.items
.iter()
.map(|(line, count)| {
max_value = std::cmp::max(*count, max_value);
ListItem::new(Line::from(line.to_string())).style(Style::default())
})
.collect();
let title: &str = self.file;
let items = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
f.render_stateful_widget(items, chunks[0], &mut self.items.state);
let heatmap: Vec<ListItem> = self
.items
.items
.iter()
.map(|(_line, count)| {
let line = Line::from(vec![" ".into()]);
let color = if max_value > 0 {
Color::Rgb((count * 255 / max_value) as u8, 0, 0)
} else {
Color::Rgb(0, 0, 0)
};
ListItem::new(line).style(Style::default().bg(color))
})
.collect();
let heatmap = List::new(heatmap)
.block(Block::default().borders(Borders::ALL).title("Heat"))
.highlight_style(Style::default());
f.render_stateful_widget(heatmap, chunks[1], &mut self.items.state);
let num_lines = self.items.items.len();
let mut current_count = 0;
let bucket = std::cmp::max(num_lines / (f.size().height as usize - 4), 1);
let mut max_value = 0;
let file_overview: Vec<_> = self
.items
.items
.iter()
.enumerate()
.filter_map(|(line_no, (_line, count))| {
current_count += count;
if (line_no + 1) % bucket == 0 {
let count = current_count;
if count > max_value {
max_value = count;
}
current_count = 0;
Some((line_no + 1 - bucket, count))
} else {
None
}
})
.collect();
let file_heatmap: Vec<ListItem> = file_overview
.into_iter()
.map(|(line_no, count)| {
let line = Line::from(vec![" ".into()]);
let color = match (self.items.state.selected(), max_value) {
(Some(selected), _) if selected > line_no && selected < line_no + bucket => {
Color::Rgb(0, 0, 255)
}
(_, max_value) if max_value > 0 => {
Color::Rgb((count * 255 / max_value) as u8, 0, 0)
}
_ => Color::Rgb(0, 0, 0),
};
ListItem::new(line).style(Style::default().bg(color))
})
.collect();
let file_heatmap = List::new(file_heatmap)
.block(Block::default().borders(Borders::ALL).title("File heat"))
.highlight_style(Style::default());
f.render_widget(file_heatmap, chunks[2]);
}
}
#[cfg(test)]
mod tests {
use super::*;
const NEW_FILE: &'static str = r#"commit 549f59a7bf0c348b17ce682725b822f02e8122d2
Author: Nicolas Patry <patry.nicolas@protonmail.com>
Date: Sun Aug 20 18:27:43 2023 +0200
Title
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2ae0fef
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,2 @@
+[package]
+name = "codesniff"
"#;
const LINE_FOLLOW: &'static str = r#"commit 549f59a7bf0c348b17ce682725b822f02e8122d2
Author: Nicolas Patry <patry.nicolas@protonmail.com>
Date: Sun Aug 20 18:27:43 2023 +0200
5.
diff --git a/test/test.txt b/test/test.txt
index ec0a8fa..5be6427 100644
--- a/test/test.txt
+++ b/test/test.txt
@@ -1,3 +1,4 @@
Line yellow: count: 1
-Line red: count: 4
-Line purple: count: 1
+Line red: count: 5
+Line purple: count: 2
+Line green: count: 1
commit 621a9685d2fa18a0a83ef820932f15f514f7ee26
Author: Nicolas Patry <patry.nicolas@protonmail.com>
Date: Sun Aug 20 18:27:14 2023 +0200
4.
diff --git a/test/test.txt b/test/test.txt
index bc78bac..ec0a8fa 100644
--- a/test/test.txt
+++ b/test/test.txt
@@ -1,4 +1,3 @@
-Line blue: count: 1
Line yellow: count: 1
-Line red: count: 3
+Line red: count: 4
Line purple: count: 1
commit 2cafab8027624c7210174a68b1c5cf0e457c4879
Author: Nicolas Patry <patry.nicolas@protonmail.com>
Date: Sun Aug 20 18:26:53 2023 +0200
3.
diff --git a/test/test.txt b/test/test.txt
index 9117d95..bc78bac 100644
--- a/test/test.txt
+++ b/test/test.txt
@@ -1,3 +1,4 @@
Line blue: count: 1
-Line red: count: 2
+Line yellow: count: 1
+Line red: count: 3
Line purple: count: 1
commit 3d48ad25815605d219dc2fd10aea12d052424a56
Author: Nicolas Patry <patry.nicolas@protonmail.com>
Date: Sun Aug 20 18:26:28 2023 +0200
2
diff --git a/test/test.txt b/test/test.txt
index 53d4777..9117d95 100644
--- a/test/test.txt
+++ b/test/test.txt
@@ -1,3 +1,3 @@
Line blue: count: 1
-Line red: count: 1
+Line red: count: 2
Line purple: count: 1
commit ba6536e2adc1eecba9d9692ec09146dfba18e394
Author: Nicolas Patry <patry.nicolas@protonmail.com>
Date: Sun Aug 20 18:26:12 2023 +0200
Initial commit
diff --git a/test/test.txt b/test/test.txt
new file mode 100644
index 0000000..53d4777
--- /dev/null
+++ b/test/test.txt
@@ -0,0 +1,3 @@
+Line blue: count: 1
+Line red: count: 1
+Line purple: count: 1
"#;
#[test]
fn test_parse_commit() {
let map = parse(NEW_FILE);
let commit = "549f59a7bf0c348b17ce682725b822f02e8122d2";
assert_eq!(
map,
HashMap::from([(1, HashSet::from([commit])), (2, HashSet::from([commit]))])
);
}
#[test]
fn test_line_follow_1() {
let patch = LINE_FOLLOW.lines().collect::<Vec<_>>();
let patch = &patch[patch.len() - 15..].join("\n");
let map = parse(patch);
let map: HashMap<_, _> = map
.into_iter()
.map(|(line, set)| (line, set.len()))
.collect();
assert_eq!(map, HashMap::from([(1, 1), (2, 1), (3, 1)]));
}
#[test]
fn test_line_follow_2() {
let map = parse(LINE_FOLLOW);
let map: HashMap<_, _> = map
.into_iter()
.map(|(line, set)| (line, set.len()))
.collect();
assert_eq!(map, HashMap::from([(1, 4), (2, 4), (3, 4), (4, 2)]));
}
}