diff --git a/src/Commands/Statistics.cs b/src/Commands/Statistics.cs new file mode 100644 index 00000000..9f1f6ec2 --- /dev/null +++ b/src/Commands/Statistics.cs @@ -0,0 +1,32 @@ +using System; + +namespace SourceGit.Commands { + public class Statistics : Command { + public Statistics(string repo) { + _statistics = new Models.Statistics(); + + WorkingDirectory = repo; + Context = repo; + Args = $"log --date-order --branches --remotes --since=\"{_statistics.Since()}\" --date=unix --pretty=format:\"%ad$%an\""; + } + + public Models.Statistics Result() { + Exec(); + _statistics.Complete(); + return _statistics; + } + + protected override void OnReadline(string line) { + var dateEndIdx = line.IndexOf('$', StringComparison.Ordinal); + if (dateEndIdx == -1) return; + + var dateStr = line.Substring(0, dateEndIdx); + var date = 0.0; + if (!double.TryParse(dateStr, out date)) return; + + _statistics.AddCommit(line.Substring(dateEndIdx + 1), date); + } + + private Models.Statistics _statistics = null; + } +} diff --git a/src/Models/Statistics.cs b/src/Models/Statistics.cs new file mode 100644 index 00000000..8bc4c029 --- /dev/null +++ b/src/Models/Statistics.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; + +namespace SourceGit.Models { + public class Sample { + public string Name { get; set; } + public int Count { get; set; } + } + + public class Statistics { + public int TotalYear { get; set; } = 0; + public int TotalMonth { get; set; } = 0; + public int TotalWeek { get; set; } = 0; + + public List Year { get; set; } = new List(); + public List Month { get; set; } = new List(); + public List Week { get; set; } = new List(); + + public List YearByAuthor { get; set; } = new List(); + public List MonthByAuthor { get; set; } = new List(); + public List WeekByAuthor { get; set; } = new List(); + + public Statistics() { + _utcStart = DateTime.UnixEpoch; + _today = DateTime.Today; + _thisWeekStart = _today.AddSeconds(-(int)_today.DayOfWeek * 3600 * 24 - _today.Hour * 3600 - _today.Minute * 60 - _today.Second); + _thisWeekEnd = _thisWeekStart.AddDays(7); + + string[] monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + + for (int i = 0; i < monthNames.Length; i++) { + Year.Add(new Sample { + Name = monthNames[i], + Count = 0, + }); + } + + var monthDays = DateTime.DaysInMonth(_today.Year, _today.Month); + for (int i = 0; i < monthDays; i++) { + Month.Add(new Sample { + Name = $"{i + 1}", + Count = 0, + }); + } + + string[] weekDayNames = [ + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + ]; + + for (int i = 0; i < weekDayNames.Length; i++) { + Week.Add(new Sample { + Name = weekDayNames[i], + Count = 0, + }); + } + } + + public string Since() { + return _today.ToString("yyyy-01-01 00:00:00"); + } + + public void AddCommit(string author, double timestamp) { + var authorTime = _utcStart.AddSeconds(timestamp); + if (authorTime.CompareTo(_thisWeekStart) >= 0 && authorTime.CompareTo(_thisWeekEnd) < 0) { + Week[(int)authorTime.DayOfWeek].Count++; + TotalWeek++; + AddByAuthor(_mapWeekByAuthor, WeekByAuthor, author); + } + + if (authorTime.Month == _today.Month) { + Month[authorTime.Day - 1].Count++; + TotalMonth++; + AddByAuthor(_mapMonthByAuthor, MonthByAuthor, author); + } + + Year[authorTime.Month - 1].Count++; + TotalYear++; + AddByAuthor(_mapYearByAuthor, YearByAuthor, author); + } + + public void Complete() { + _mapYearByAuthor.Clear(); + _mapMonthByAuthor.Clear(); + _mapWeekByAuthor.Clear(); + + YearByAuthor.Sort((l, r) => r.Count - l.Count); + MonthByAuthor.Sort((l, r) => r.Count - l.Count); + WeekByAuthor.Sort((l, r) => r.Count - l.Count); + } + + private void AddByAuthor(Dictionary map, List collection, string author) { + if (map.ContainsKey(author)) { + map[author].Count++; + } else { + var sample = new Sample { Name = author, Count = 1 }; + map.Add(author, sample); + collection.Add(sample); + } + } + + private DateTime _utcStart; + private DateTime _today; + private DateTime _thisWeekStart; + private DateTime _thisWeekEnd; + + private Dictionary _mapYearByAuthor = new Dictionary(); + private Dictionary _mapMonthByAuthor = new Dictionary(); + private Dictionary _mapWeekByAuthor = new Dictionary(); + } +} diff --git a/src/Resources/Icons.axaml b/src/Resources/Icons.axaml index f07a3eb2..5b652d8a 100644 --- a/src/Resources/Icons.axaml +++ b/src/Resources/Icons.axaml @@ -82,4 +82,5 @@ M734 128c-33-19-74-8-93 25l-41 70c-28-6-58-9-90-9-294 0-445 298-445 298s82 149 231 236l-31 54c-19 33-8 74 25 93 33 19 74 8 93-25L759 222C778 189 767 147 734 128zM305 512c0-115 93-208 207-208 14 0 27 1 40 4l-37 64c-1 0-2 0-2 0-77 0-140 63-140 140 0 26 7 51 20 71l-37 64C324 611 305 564 305 512zM771 301 700 423c13 27 20 57 20 89 0 110-84 200-192 208l-51 89c12 1 24 2 36 2 292 0 446-298 446-298S895 388 771 301z M469 235V107h85v128h-85zm-162-94 85 85-60 60-85-85 60-60zm469 60-85 85-60-60 85-85 60 60zm-549 183A85 85 0 01302 341H722a85 85 0 0174 42l131 225A85 85 0 01939 652V832a85 85 0 01-85 85H171a85 85 0 01-85-85v-180a85 85 0 0112-43l131-225zM722 427H302l-100 171h255l10 29a59 59 0 002 5c2 4 5 9 9 14 8 9 18 17 34 17 16 0 26-7 34-17a72 72 0 0011-18l0-0 10-29h255l-100-171zM853 683H624a155 155 0 01-12 17C593 722 560 747 512 747c-48 0-81-25-99-47a155 155 0 01-12-17H171v149h683v-149z M719 85 388 417l-209-165L87 299v427l92 47 210-164L720 939 939 850V171zM186 610V412l104 104zm526 55L514 512l198-153z + M296 912H120c-4.4 0-8-3.6-8-8V520c0-4.4 3.6-8 8-8h176c4.4 0 8 3.6 8 8v384c0 4.4-3.6 8-8 8zM600 912H424c-4.4 0-8-3.6-8-8V121c0-4.4 3.6-8 8-8h176c4.4 0 8 3.6 8 8v783c0 4.4-3.6 8-8 8zM904 912H728c-4.4 0-8-3.6-8-8V280c0-4.4 3.6-8 8-8h176c4.4 0 8 3.6 8 8v624c0 4.4-3.6 8-8 8z diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 5c58a754..1834115e 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -147,8 +147,6 @@ SUBMODULES ADD SUBMODULE UPDATE SUBMODULE - SUBTREES - ADD/LINK SUBTREE RESOLVE CONTINUE ABORT @@ -459,6 +457,15 @@ REMOVE NO FILES ASSUMED AS UNCHANGED + Statistics + WEEK + MONTH + YEAR + Total Authors + Total Commits + AUTHOR + COMMITS + Git has NOT been configured. Please to go [Preference] and configure it first. BINARY FILE NOT SUPPORTED!!! BLAME ON THIS FILE IS NOT SUPPORTED!!! diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index bab81474..e8a43731 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -146,8 +146,6 @@ 子模块列表 添加子模块 更新子模块 - 子树列表 - 添加子树 解决冲突 下一步 终止冲突解决 @@ -458,6 +456,15 @@ 移除 没有不跟踪更改的文件 + 提交统计 + 本周 + 本月 + 本年 + 作者人数 + 总计提交次数 + 作者 + 提交次数 + GIT尚未配置。请打开【偏好设置】配置GIT路径。 二进制文件不支持该操作!!! 选中文件不支持该操作!!! diff --git a/src/ViewModels/Statistics.cs b/src/ViewModels/Statistics.cs new file mode 100644 index 00000000..b72df346 --- /dev/null +++ b/src/ViewModels/Statistics.cs @@ -0,0 +1,33 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using System.Threading.Tasks; + +namespace SourceGit.ViewModels { + public class Statistics : ObservableObject { + public bool IsLoading { + get => _isLoading; + private set => SetProperty(ref _isLoading, value); + } + + public Models.Statistics Data { + get => _data; + private set => SetProperty(ref _data, value); + } + + public Statistics(string repo) { + _repo = repo; + + Task.Run(() => { + var result = new Commands.Statistics(_repo).Result(); + Dispatcher.UIThread.Invoke(() => { + IsLoading = false; + Data = result; + }); + }); + } + + private string _repo = string.Empty; + private bool _isLoading = true; + private Models.Statistics _data = null; + } +} diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index 94e11959..fc581b54 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -81,6 +81,10 @@ + + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index beac2721..3c846afb 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -188,6 +188,14 @@ namespace SourceGit.Views { } } } + + private async void OpenStatistics(object sender, RoutedEventArgs e) { + if (DataContext is ViewModels.Repository repo) { + var dialog = new Statistics() { DataContext = new ViewModels.Statistics(repo.FullPath) }; + await dialog.ShowDialog(TopLevel.GetTopLevel(this) as Window); + e.Handled = true; + } + } } } diff --git a/src/Views/Statistics.axaml b/src/Views/Statistics.axaml new file mode 100644 index 00000000..01cd4ce8 --- /dev/null +++ b/src/Views/Statistics.axaml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/Statistics.axaml.cs b/src/Views/Statistics.axaml.cs new file mode 100644 index 00000000..1b9b1f56 --- /dev/null +++ b/src/Views/Statistics.axaml.cs @@ -0,0 +1,185 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace SourceGit.Views { + public class Chart : Control { + public static readonly StyledProperty FontFamilyProperty = + AvaloniaProperty.Register(nameof(FontFamily)); + + public FontFamily FontFamily { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + public static readonly StyledProperty LineBrushProperty = + AvaloniaProperty.Register(nameof(LineBrush), Brushes.Gray); + + public IBrush LineBrush { + get => GetValue(LineBrushProperty); + set => SetValue(LineBrushProperty, value); + } + + public static readonly StyledProperty ShapeBrushProperty = + AvaloniaProperty.Register(nameof(ShapeBrush), Brushes.Gray); + + public IBrush ShapeBrush { + get => GetValue(ShapeBrushProperty); + set => SetValue(ShapeBrushProperty, value); + } + + public static readonly StyledProperty> SamplesProperty = + AvaloniaProperty.Register>(nameof(Samples), null); + + public List Samples { + get => GetValue(SamplesProperty); + set => SetValue(SamplesProperty, value); + } + + static Chart() { + AffectsRender(SamplesProperty); + } + + public override void Render(DrawingContext context) { + if (Samples == null) return; + + var samples = Samples; + int maxV = 0; + foreach (var s in samples) { + if (maxV < s.Count) maxV = s.Count; + } + + if (maxV < 5) { + maxV = 5; + } else if (maxV < 10) { + maxV = 10; + } else if (maxV < 50) { + maxV = 50; + } else if (maxV < 100) { + maxV = 100; + } else if (maxV < 200) { + maxV = 200; + } else if (maxV < 500) { + maxV = 500; + } else { + maxV = (int)Math.Ceiling(maxV / 500.0) * 500; + } + + var typeface = new Typeface(FontFamily); + var pen = new Pen(LineBrush, 1); + var width = Bounds.Width; + var height = Bounds.Height; + + // Draw coordinate + var maxLabel = new FormattedText($"{maxV}", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, typeface, 12.0, LineBrush); + var horizonStart = maxLabel.Width + 8; + var labelHeight = 32; + context.DrawText(maxLabel, new Point(0, -maxLabel.Height * 0.5)); + context.DrawLine(pen, new Point(horizonStart, 0), new Point(horizonStart, height - labelHeight)); + context.DrawLine(pen, new Point(horizonStart, height - labelHeight), new Point(width, height - labelHeight)); + + if (samples.Count == 0) return; + + // Draw horizontal lines + var stepX = (width - horizonStart) / samples.Count; + var stepV = (height - labelHeight) / 5; + var labelStepV = maxV / 5; + var gridPen = new Pen(LineBrush, 1, new DashStyle()); + for (int i = 1; i < 5; i++) { + var vLabel = new FormattedText( + $"{maxV - i * labelStepV}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + 12.0, + LineBrush); + + var dashHeight = i * stepV; + var vy = Math.Max(0, dashHeight - vLabel.Height * 0.5); + using (context.PushOpacity(.1)) { + context.DrawLine(gridPen, new Point(horizonStart + 1, dashHeight), new Point(width, dashHeight)); + } + context.DrawText(vLabel, new Point(horizonStart - vLabel.Width - 8, vy)); + } + + // Calculate hit boxes + var shapeWidth = Math.Min(32, stepX - 4); + var hitboxes = new List(); + for (int i = 0; i < samples.Count; i++) { + var h = samples[i].Count * (height - labelHeight) / maxV; + var x = horizonStart + 1 + stepX * i + (stepX - shapeWidth) * 0.5; + var y = height - labelHeight - h; + hitboxes.Add(new Rect(x, y, shapeWidth, h)); + } + + // Draw shapes + for (int i = 0; i < samples.Count; i++) { + var hLabel = new FormattedText( + samples[i].Name, + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + 10.0, + LineBrush); + var rect = hitboxes[i]; + var xLabel = rect.X - (hLabel.Width - rect.Width) * 0.5; + var yLabel = height - labelHeight + 4; + + context.DrawRectangle(ShapeBrush, null, rect); + + if (stepX < 32) { + var matrix = Matrix.CreateTranslation(hLabel.Width * 0.5, -hLabel.Height * 0.5) // Center of label + * Matrix.CreateRotation(Math.PI * 0.25) // Rotate + * Matrix.CreateTranslation(xLabel, yLabel); // Move + using (context.PushTransform(matrix)) { + context.DrawText(hLabel, new Point(0, 0)); + } + } else { + context.DrawText(hLabel, new Point(xLabel, yLabel)); + } + } + + // Draw labels on hover + for (int i = 0; i < samples.Count; i++) { + var rect = hitboxes[i]; + if (rect.Contains(_mousePos)) { + var tooltip = new FormattedText( + $"{samples[i].Count}", + CultureInfo.CurrentCulture, + FlowDirection.LeftToRight, + typeface, + 12.0, + LineBrush); + + var tx = rect.X - (tooltip.Width - rect.Width) * 0.5; + var ty = rect.Y - tooltip.Height - 4; + context.DrawText(tooltip, new Point(tx, ty)); + break; + } + } + } + + protected override void OnPointerMoved(PointerEventArgs e) { + base.OnPointerMoved(e); + _mousePos = e.GetPosition(this); + InvalidateVisual(); + } + + private Point _mousePos = new Point(0, 0); + } + + public partial class Statistics : Window { + public Statistics() { + InitializeComponent(); + } + + private void CloseWindow(object sender, RoutedEventArgs e) { + Close(); + } + } +}