use regex::RegexBuilder;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::{
error::Error,
io,
time::{Duration, Instant},
};
mod stateful;
mod viewer;
use crate::stateful::StatefulList;
use crate::viewer::Viewer;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::*, widgets::*};
#[allow(unused_macros)]
macro_rules! log {
($e: expr) => {
use ::std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.append(true)
.open("log.txt")
.unwrap();
writeln!(file, $e).unwrap()
};
}
#[derive(Debug)]
#[allow(dead_code)]
struct Commit {
sha: String,
title: String,
files: Vec<String>,
}
enum State {
Searching,
Files,
View,
}
struct GlobalState {
commits: Vec<Commit>,
file: Option<(String, String, String)>,
}
impl GlobalState {
pub fn new(commits: Vec<Commit>) -> Self {
Self {
commits,
file: None,
}
}
}
struct App<'a> {
items: StatefulList<Modified<'a>>,
search: String,
state: State,
}
impl<'a> App<'a> {
fn new(commits: &'a [Commit]) -> Self {
let most_modified = most_modified(commits.iter());
let items = StatefulList::with_items(most_modified);
let search = String::new();
let state = State::Files;
Self {
items,
search,
state,
}
}
fn update_filtered(&mut self, commits: &'a [Commit]) {
let most_modified = if let Ok(search) = RegexBuilder::new(&self.search)
.case_insensitive(true)
.build()
{
let filtered_commits = commits.iter().filter_map(|commit| {
if search.is_match(&commit.title) {
Some(commit)
} else {
None
}
});
most_modified(filtered_commits)
} else {
most_modified(commits.iter())
};
let items = StatefulList::with_items(most_modified);
self.items = items;
}
}
enum ParseState {
Sha,
Title,
Files,
}
fn parse(output: String) -> Vec<Commit> {
let mut commits = vec![];
let mut state = ParseState::Sha;
let mut current_sha = "".to_string();
let mut current_title = "".to_string();
let mut current_files = vec![];
for line in output.lines() {
let line = line.trim().to_string();
match state {
ParseState::Sha => {
current_sha = line;
state = ParseState::Title;
}
ParseState::Title => {
if line.is_empty() {
state = ParseState::Files;
} else {
current_title = line;
}
}
ParseState::Files => {
if line.is_empty() {
if !current_files.is_empty() {
let commit = Commit {
sha: current_sha.clone(),
title: current_title.clone(),
files: current_files.clone(),
};
commits.push(commit);
current_files.clear();
state = ParseState::Sha;
}
} else {
current_files.push(line);
}
}
}
}
let commit = Commit {
sha: current_sha.clone(),
title: current_title.clone(),
files: current_files.clone(),
};
commits.push(commit);
commits
}
struct Modified<'a> {
file: &'a String,
commits: Vec<&'a Commit>,
}
fn most_modified<'a, I: Iterator<Item = &'a Commit>>(commits: I) -> Vec<Modified<'a>> {
let mut map = HashMap::new();
for commit in commits {
for file in &commit.files {
let path = Path::new(file);
if path.exists() {
map.entry(file).or_insert(vec![]).push(commit);
}
}
}
let mut vec: Vec<_> = map.into_iter().collect();
vec.sort_by(|(_, a), (_, b)| b.len().cmp(&a.len()));
vec.into_iter()
.map(|(file, commits)| Modified { file, commits })
.collect()
}
fn main() -> Result<(), Box<dyn Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let tick_rate = Duration::from_millis(250);
let output = Command::new("git")
.args(["log", "--pretty=%n%h%n%s%n", "--name-only"])
.output()
.expect(
"`git` is required to run `codesniff`. Install it and make it available in your path",
);
let output = String::from_utf8(output.stdout).expect(" Expected valid utf-8");
let commits = parse(output);
let res = run_app(&mut terminal, commits, tick_rate);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App, viewer: &mut Option<Viewer>) {
if let Some(viewer) = viewer {
viewer.render(f);
} else {
let main = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)].as_ref())
.split(f.size());
let search = Span::raw(app.search.clone());
let color = if let State::Searching = app.state {
Color::Red
} else {
Color::White
};
let search = List::new(vec![ListItem::new(search)]).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.title("Filter commit"),
);
f.render_widget(search, main[0]);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Length(4)].as_ref())
.split(main[1]);
let width = f.size().width as usize;
let mut max_value = 0;
let items: Vec<ListItem> = app
.items
.items
.iter()
.map(|Modified { file, commits }| {
let count = commits.len();
max_value = std::cmp::max(count, max_value);
let lines = vec![Line::from(vec![
format!("{: <1$}", file, width - 12).into(),
" ".into(),
format!("{: >4}", count).into(),
])];
ListItem::new(lines).style(Style::default())
})
.collect();
let items = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Modified Files"),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(items, chunks[0], &mut app.items.state);
let heatmap: Vec<ListItem> = app
.items
.items
.iter()
.map(|Modified { commits, .. }| {
let count = commits.len();
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 app.items.state);
}
}
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
commits: Vec<Commit>,
tick_rate: Duration,
) -> io::Result<()> {
let mut global_state = GlobalState::new(commits);
let mut app = App::new(&global_state.commits);
let mut viewer = None;
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui(f, &mut app, &mut viewer))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match &mut app.state {
State::Searching => match key.code {
KeyCode::Char(x) => {
app.search.push(x);
app.update_filtered(&global_state.commits);
}
KeyCode::Backspace => {
app.search.pop();
app.update_filtered(&global_state.commits);
}
KeyCode::Esc | KeyCode::Enter => {
app.state = State::Files;
}
_ => {}
},
State::Files => match key.code {
KeyCode::Esc | KeyCode::Char('q') => return Ok(()),
KeyCode::Left => app.items.unselect(),
KeyCode::Right | KeyCode::Enter => {
let i = app.items.state.selected().unwrap();
let file = app.items.items[i].file;
let content = std::fs::read_to_string(&file).unwrap();
let output = Command::new("git")
.args(["log", "-p", "--follow", file.as_str()])
.output()
.expect(
"`git` is required to run `codesniff`. Install it and make it available in your path",
);
let gitlog = String::from_utf8(output.stdout)
.expect(" Expected valid utf-8");
global_state.file = Some((file.clone(), content, gitlog));
let (file, content, gitlog) = &global_state.file.as_ref().unwrap();
viewer = Some(Viewer::new(file, content, gitlog));
app.state = State::View;
}
KeyCode::Up => app.items.previous(),
KeyCode::Down => app.items.next(),
KeyCode::Char('/') => {
app.state = State::Searching;
}
_ => {}
},
State::View => match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.state = State::Files,
k => {
if let Some(viewer) = &mut viewer {
viewer.handle_key(k)
}
}
},
}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
}