feat: limit authors in tui, better basic view
also they are sorted now! and cleaned up a little
This commit is contained in:
parent
69319bedb2
commit
070e3428f7
4 changed files with 115 additions and 73 deletions
|
@ -13,7 +13,7 @@ rand = "0.8.5"
|
||||||
clap = { version = "4.4.8", features = ["derive"] }
|
clap = { version = "4.4.8", features = ["derive"] }
|
||||||
# TUI
|
# TUI
|
||||||
crossterm = { version = "0.27", optional = true }
|
crossterm = { version = "0.27", optional = true }
|
||||||
ratatui = { version = "0.24", features = ["all-widgets"], optional = true }
|
ratatui = { version = "0.26", features = ["all-widgets"], optional = true }
|
||||||
# GUI
|
# GUI
|
||||||
eframe = { version = "0.24", optional = true }
|
eframe = { version = "0.24", optional = true }
|
||||||
egui_plot = { version = "0.24", optional = true }
|
egui_plot = { version = "0.24", optional = true }
|
||||||
|
|
25
src/gui.rs
25
src/gui.rs
|
@ -1,10 +1,9 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use chrono::{DateTime, Local, Utc, NaiveDateTime};
|
|
||||||
use eframe::{egui::{self, Layout}, emath::{Vec2b, Align}};
|
use eframe::{egui::{self, Layout}, emath::{Vec2b, Align}};
|
||||||
use egui_plot::{Plot, Legend, Line, PlotPoints};
|
use egui_plot::{Plot, Legend, Line, PlotPoints};
|
||||||
|
|
||||||
use crate::CommitActivityData;
|
use crate::{timestamp_to_str, CommitActivityData};
|
||||||
|
|
||||||
fn tuple_to_vec(vec: Vec<(f64, f64)>) -> Vec<[f64; 2]> {
|
fn tuple_to_vec(vec: Vec<(f64, f64)>) -> Vec<[f64; 2]> {
|
||||||
vec.iter().map(|(x,y)| [*x,*y]).collect()
|
vec.iter().map(|(x,y)| [*x,*y]).collect()
|
||||||
|
@ -28,12 +27,12 @@ struct GitStatsApp {
|
||||||
impl GitStatsApp {
|
impl GitStatsApp {
|
||||||
fn new(data: CommitActivityData) -> Self {
|
fn new(data: CommitActivityData) -> Self {
|
||||||
let mut author_data = HashMap::new();
|
let mut author_data = HashMap::new();
|
||||||
for (author, data) in data.per_author {
|
for (author, data) in data.timeline_per_author {
|
||||||
author_data.insert(author, tuple_to_vec(data));
|
author_data.insert(author, tuple_to_vec(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
total_data: tuple_to_vec(data.total),
|
total_data: tuple_to_vec(data.timeline),
|
||||||
author_data,
|
author_data,
|
||||||
lock: true,
|
lock: true,
|
||||||
}
|
}
|
||||||
|
@ -88,21 +87,3 @@ impl eframe::App for GitStatsApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn timestamp_to_str(t: i64, date: bool, time: bool) -> String {
|
|
||||||
format!(
|
|
||||||
"{}",
|
|
||||||
DateTime::<Local>::from(DateTime::<Utc>::from_utc(
|
|
||||||
NaiveDateTime::from_timestamp(t, 0),
|
|
||||||
Utc
|
|
||||||
))
|
|
||||||
.format(if date && time {
|
|
||||||
"%Y/%m/%d %H:%M:%S"
|
|
||||||
} else if date {
|
|
||||||
"%Y/%m/%d"
|
|
||||||
} else if time {
|
|
||||||
"%H:%M:%S"
|
|
||||||
} else {
|
|
||||||
"%s"
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
145
src/main.rs
145
src/main.rs
|
@ -1,7 +1,7 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use git2::{Repository, Commit, Oid};
|
use git2::{Commit, Oid, Repository};
|
||||||
use chrono::{DateTime, Utc, Duration};
|
use chrono::{DateTime, Utc, Duration};
|
||||||
|
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
|
@ -11,31 +11,45 @@ mod tui;
|
||||||
mod gui;
|
mod gui;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
struct CliArgs {
|
/// a simple git statistics display tool in your terminal
|
||||||
|
struct GittsArgs {
|
||||||
|
/// path of git repo to operate on, defaults to cwd
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
|
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
|
/// display mode for statistics, defaults to basic
|
||||||
mode: Option<CliViewMode>,
|
mode: Option<CliViewMode>,
|
||||||
|
|
||||||
|
#[arg(short = '1', long, default_value_t = false)]
|
||||||
|
/// reduce per-author commits by 1 when zeroed (helps with TUI overlaps)
|
||||||
|
minus_one: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum CliViewMode {
|
enum CliViewMode {
|
||||||
|
/// simple no-dependency display mode, lists authors and their commit count
|
||||||
Basic,
|
Basic,
|
||||||
|
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
|
/// terminal user interface, shows a timeline plot with all commits and per author
|
||||||
Tui {
|
Tui {
|
||||||
|
|
||||||
#[clap(long, default_value_t = false)]
|
#[clap(short, long, default_value_t = false)]
|
||||||
no_total: bool,
|
/// draw a line for total commits
|
||||||
|
total: bool,
|
||||||
|
|
||||||
|
#[clap(short = 'a', long, default_value_t = 5)]
|
||||||
|
/// max authors to display in legend
|
||||||
|
max_authors: usize,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
|
/// graphical user interface, shows a whole plot window with togglable user and total stats
|
||||||
Gui,
|
Gui,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = CliArgs::parse();
|
let args = GittsArgs::parse();
|
||||||
|
|
||||||
let repo = Repository::open(args.path.as_deref().unwrap_or(".")).unwrap();
|
let repo = Repository::open(args.path.as_deref().unwrap_or(".")).unwrap();
|
||||||
|
|
||||||
|
@ -56,14 +70,14 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = process(store).unwrap();
|
let data = process(store, args.minus_one).unwrap();
|
||||||
|
|
||||||
match args.mode.unwrap_or(CliViewMode::Basic) {
|
match args.mode.unwrap_or(CliViewMode::Basic) {
|
||||||
|
|
||||||
CliViewMode::Basic => basic_display(data),
|
CliViewMode::Basic => basic_display(data),
|
||||||
|
|
||||||
#[cfg(feature = "tui")]
|
#[cfg(feature = "tui")]
|
||||||
CliViewMode::Tui { no_total } => tui::display(data, !no_total).unwrap(),
|
CliViewMode::Tui { total, max_authors } => tui::display(data, total, max_authors).unwrap(),
|
||||||
|
|
||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
CliViewMode::Gui => gui::display(data).unwrap(),
|
CliViewMode::Gui => gui::display(data).unwrap(),
|
||||||
|
@ -71,80 +85,121 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn author_display(commit: &Commit, full: bool) -> String {
|
pub fn author_display(commit: &Commit) -> String {
|
||||||
if full {
|
format!("{} <{}>", commit.author().name().unwrap_or(""), commit.author().email().unwrap_or(""))
|
||||||
format!("{} <{}>", commit.author().name().unwrap_or(""), commit.author().email().unwrap_or(""))
|
|
||||||
} else {
|
|
||||||
commit.author().name().unwrap_or("").to_string()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Data = Vec<(f64, f64)>;
|
type Data = Vec<(f64, f64)>;
|
||||||
|
|
||||||
pub struct CommitActivityData {
|
pub struct CommitActivityData {
|
||||||
pub total: Data,
|
pub timeline: Data,
|
||||||
pub per_author: HashMap<String, Data>,
|
pub timeline_per_author: HashMap<String, Data>,
|
||||||
|
pub count_per_author: HashMap<String, u32>,
|
||||||
|
pub authors_by_count: Vec<String>,
|
||||||
pub oldest: DateTime<Utc>,
|
pub oldest: DateTime<Utc>,
|
||||||
|
pub now: DateTime<Utc>,
|
||||||
pub max_count: f64,
|
pub max_count: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process(
|
pub fn process(
|
||||||
commits: HashMap<Oid, (DateTime<Utc>, Commit)>,
|
commits: HashMap<Oid, (DateTime<Utc>, Commit)>,
|
||||||
|
minus_one: bool,
|
||||||
) -> Result<CommitActivityData, Box<dyn std::error::Error>> {
|
) -> Result<CommitActivityData, Box<dyn std::error::Error>> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let min = *commits.values().map(|(t, _c)| t).min().unwrap();
|
let oldest = *commits.values().map(|(t, _c)| t).min().unwrap();
|
||||||
let bins = (now - min).num_weeks() as usize;
|
let bins = (now - oldest).num_weeks() as usize;
|
||||||
|
|
||||||
let mut data = vec![(0f64, 0f64); bins + 1];
|
let mut timeline = vec![(0f64, 0f64); bins + 1];
|
||||||
let authors : HashSet<String> = commits.values().map(|(_t, c)| crate::author_display(c, false)).collect();
|
let authors : HashSet<String> = commits.values().map(|(_t, c)| crate::author_display(c)).collect();
|
||||||
let mut authors_data : HashMap<String,Vec<(f64, f64)>> = HashMap::new();
|
let mut timeline_per_author : HashMap<String, Vec<(f64, f64)>> = HashMap::new();
|
||||||
|
let mut count_per_author : HashMap<String, u32> = HashMap::new();
|
||||||
for author in authors {
|
for author in authors {
|
||||||
authors_data.insert(author, vec![(0f64, -1.0f64); bins + 1]);
|
timeline_per_author.insert(author.clone(), vec![(0f64, if minus_one { -1.0f64 } else { 0.0f64 }); bins + 1]);
|
||||||
|
count_per_author.insert(author, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut y_max = 0.;
|
let mut max_count = 0.;
|
||||||
for (t, c) in commits.values() {
|
for (t, c) in commits.values() {
|
||||||
let bin = (*t - min).num_weeks() as usize;
|
let bin = (*t - oldest).num_weeks() as usize;
|
||||||
data[bin].1 += 1.;
|
let author = crate::author_display(c);
|
||||||
if data[bin].1 > y_max {
|
timeline[bin].1 += 1.;
|
||||||
y_max = data[bin].1;
|
if timeline[bin].1 > max_count {
|
||||||
}
|
max_count = timeline[bin].1;
|
||||||
if let Some(x) = authors_data.get_mut(&crate::author_display(c, false)) {
|
|
||||||
if x[bin].1 < 0. { x[bin].1 = 0. };
|
|
||||||
if bin > 0 && x[bin-1].1 < 0. { x[bin-1].1 = 0. };
|
|
||||||
if bin < x.len() - 1 && x[bin+1].1 < 0. { x[bin+1].1 = 0. };
|
|
||||||
x[bin].1 += 1.;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let author_count = count_per_author
|
||||||
|
.get_mut(&author)
|
||||||
|
.expect("unknown author, but we checked them all before?");
|
||||||
|
*author_count += 1;
|
||||||
|
|
||||||
|
let author_timeline = timeline_per_author
|
||||||
|
.get_mut(&author)
|
||||||
|
.expect("unknown author, but we checked them all before?");
|
||||||
|
// TODO ugly trick
|
||||||
|
if author_timeline[bin].1 < 0. { author_timeline[bin].1 = 0. };
|
||||||
|
if bin > 0 && author_timeline[bin-1].1 < 0. { author_timeline[bin-1].1 = 0. };
|
||||||
|
if bin < author_timeline.len() - 1 && author_timeline[bin+1].1 < 0. { author_timeline[bin+1].1 = 0. };
|
||||||
|
// TODO these are needed to "hide" zero lines in TUI and avoid overlaps, ew!
|
||||||
|
author_timeline[bin].1 += 1.;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (_name, author_d) in authors_data.iter_mut() {
|
for (_name, author_d) in timeline_per_author.iter_mut() {
|
||||||
let mut min_mut = min;
|
let mut min_mut = oldest;
|
||||||
for (x, _) in author_d.iter_mut() {
|
for (x, _) in author_d.iter_mut() {
|
||||||
*x = min_mut.timestamp() as f64;
|
*x = min_mut.timestamp() as f64;
|
||||||
min_mut += Duration::weeks(1);
|
min_mut += Duration::weeks(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut min_mut = min;
|
let mut min_mut = oldest;
|
||||||
for (x, _) in data.iter_mut() {
|
for (x, _) in timeline.iter_mut() {
|
||||||
*x = min_mut.timestamp() as f64;
|
*x = min_mut.timestamp() as f64;
|
||||||
min_mut += Duration::weeks(1);
|
min_mut += Duration::weeks(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut authors_by_count = count_per_author
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<(String, u32)>>();
|
||||||
|
authors_by_count.sort_by(|a, b| a.1.cmp(&b.1).reverse());
|
||||||
|
let authors_by_count = authors_by_count
|
||||||
|
.iter()
|
||||||
|
.map(|(u, _c)| u)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(CommitActivityData {
|
Ok(CommitActivityData {
|
||||||
total: data,
|
now,
|
||||||
per_author: authors_data,
|
timeline,
|
||||||
oldest: min,
|
timeline_per_author,
|
||||||
max_count: y_max,
|
count_per_author,
|
||||||
|
authors_by_count,
|
||||||
|
oldest,
|
||||||
|
max_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn timestamp_to_str(t: i64, date: bool, time: bool) -> String {
|
||||||
|
let mut fmt = String::new();
|
||||||
|
if date { fmt.push_str("%Y/%m/%d") }
|
||||||
|
if date && time { fmt.push(' ') }
|
||||||
|
if time { fmt.push_str("%H:%M:%S") }
|
||||||
|
if !date && !time { fmt.push_str("%s") }
|
||||||
|
DateTime::from_timestamp(t, 0)
|
||||||
|
.unwrap_or(DateTime::UNIX_EPOCH)
|
||||||
|
.format(&fmt)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn basic_display(data: CommitActivityData) {
|
pub fn basic_display(data: CommitActivityData) {
|
||||||
let total : u64 = data.total.iter().map(|(_x, y)| *y as u64).sum();
|
let total : u64 = data.timeline.iter().map(|(_x, y)| *y as u64).sum();
|
||||||
println!("total commits: {}", total);
|
println!("total commits: {}", total);
|
||||||
|
|
||||||
for (name, author_data) in data.per_author {
|
for name in data.authors_by_count {
|
||||||
let author_total : u64 = author_data.iter().map(|(_x, y)| *y as u64).sum();
|
let author_count = data.count_per_author.get(&name).expect("author in list but has no count?");
|
||||||
println!("{} commits: {}", name, author_total);
|
let author_timeline = data.timeline_per_author.get(&name).expect("author in list but has no timeline?");
|
||||||
|
let oldest = timestamp_to_str(author_timeline.iter().find(|x| x.1 > 0.0).unwrap().0 as i64, true, true);
|
||||||
|
let newest = timestamp_to_str(author_timeline.iter().rev().find(|x| x.1 > 0.0).unwrap().0 as i64, true, true);
|
||||||
|
println!("-{:>75} :{:5} commits (from {} to {})", name, author_count, oldest, newest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
16
src/tui.rs
16
src/tui.rs
|
@ -16,14 +16,20 @@ const COLORS : [Color; 10] = [
|
||||||
Color::LightMagenta,
|
Color::LightMagenta,
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn display(data: crate::CommitActivityData, total: bool) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn display(data: crate::CommitActivityData, total: bool, max_authors: usize) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// prepare chart widget
|
// prepare chart widget
|
||||||
let mut datasets = Vec::new();
|
let mut datasets = Vec::new();
|
||||||
|
let authors : Vec<String> = data.authors_by_count.iter().take(max_authors).cloned().collect();
|
||||||
|
|
||||||
for (name, author_d) in data.per_author.iter() {
|
for name in data.authors_by_count.iter() {
|
||||||
|
let author_d = data.timeline_per_author.get(name).expect("author in list has no data");
|
||||||
|
let author_count = data.count_per_author.get(name).expect("author in list has no count");
|
||||||
|
let mut ds = Dataset::default();
|
||||||
|
if authors.contains(name) {
|
||||||
|
ds = ds.name(format!("{:4}| {}", author_count, name.clone()));
|
||||||
|
}
|
||||||
datasets.push(
|
datasets.push(
|
||||||
Dataset::default()
|
ds
|
||||||
.name(name.clone())
|
|
||||||
.marker(symbols::Marker::Braille)
|
.marker(symbols::Marker::Braille)
|
||||||
.graph_type(GraphType::Line)
|
.graph_type(GraphType::Line)
|
||||||
.style(Style::default().fg(COLORS.choose(&mut rand::thread_rng()).cloned().unwrap()))
|
.style(Style::default().fg(COLORS.choose(&mut rand::thread_rng()).cloned().unwrap()))
|
||||||
|
@ -37,7 +43,7 @@ pub fn display(data: crate::CommitActivityData, total: bool) -> Result<(), Box<d
|
||||||
.marker(symbols::Marker::Braille)
|
.marker(symbols::Marker::Braille)
|
||||||
.graph_type(GraphType::Line)
|
.graph_type(GraphType::Line)
|
||||||
.style(Style::default().fg(Color::Red))
|
.style(Style::default().fg(Color::Red))
|
||||||
.data(&data.total)
|
.data(&data.timeline)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue