From 270ff8fdce44be9251fcac042b538b5b77e38dbc Mon Sep 17 00:00:00 2001 From: Philippe Miossec Date: Thu, 21 Mar 2019 14:39:28 +0100 Subject: [PATCH] Blame: Display author avatar and a recency marker in the gutter + add a setting to disable it Note: Height estimation (proposed in https://github.com/gitextensions/gitextensions/issues/6605#issuecomment-496314608 ) --- GitCommands/Settings/AppSettings.cs | 6 + GitUI/Avatars/AvatarService.cs | 2 +- .../FormFileHistory.Designer.cs | 12 +- GitUI/CommandsDialogs/FormFileHistory.cs | 8 ++ GitUI/Editor/BlameAuthorMargin.cs | 119 ++++++++++++++++++ GitUI/Editor/FileViewer.cs | 6 + GitUI/Editor/FileViewerInternal.cs | 26 ++++ GitUI/Translation/English.xlf | 4 + GitUI/UserControls/BlameControl.cs | 100 ++++++++++++++- .../UserControls/BlameControlTests.cs | 4 +- 10 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 GitUI/Editor/BlameAuthorMargin.cs diff --git a/GitCommands/Settings/AppSettings.cs b/GitCommands/Settings/AppSettings.cs index 0d38c3ebe86..0f79acf7932 100644 --- a/GitCommands/Settings/AppSettings.cs +++ b/GitCommands/Settings/AppSettings.cs @@ -1688,6 +1688,12 @@ public static bool BlameShowOriginalFilePath set => SetBool("Blame.ShowOriginalFilePath", value); } + public static bool BlameShowAuthorAvatar + { + get => GetBool("Blame.ShowAuthorAvatar", true); + set => SetBool("Blame.ShowAuthorAvatar", value); + } + public static bool IsPortable() { return Properties.Settings.Default.IsPortable; diff --git a/GitUI/Avatars/AvatarService.cs b/GitUI/Avatars/AvatarService.cs index 31c02fcd834..895208c22a6 100644 --- a/GitUI/Avatars/AvatarService.cs +++ b/GitUI/Avatars/AvatarService.cs @@ -4,7 +4,7 @@ namespace GitUI.Avatars { public static class AvatarService { - private static InitialsAvatarGenerator AvatarGenerator = new InitialsAvatarGenerator(); + private static readonly InitialsAvatarGenerator AvatarGenerator = new InitialsAvatarGenerator(); public static IAvatarProvider Default { get; } = new BackupAvatarProvider(new AvatarMemoryCache(new AvatarPersistentCache(new AvatarDownloader(AvatarGenerator), AvatarGenerator)), Images.User80); diff --git a/GitUI/CommandsDialogs/FormFileHistory.Designer.cs b/GitUI/CommandsDialogs/FormFileHistory.Designer.cs index 73a7c031db7..a89d488d641 100644 --- a/GitUI/CommandsDialogs/FormFileHistory.Designer.cs +++ b/GitUI/CommandsDialogs/FormFileHistory.Designer.cs @@ -67,6 +67,7 @@ private void InitializeComponent() this.showAuthorTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.showLineNumbersToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.showOriginalFilePathToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.showAuthorAvatarToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); this.splitContainer1.Panel1.SuspendLayout(); this.splitContainer1.Panel2.SuspendLayout(); @@ -481,6 +482,7 @@ private void InitializeComponent() this.toolStripSeparator5, this.displaySettingsToolStripMenuItem, this.displayAuthorFirstToolStripMenuItem, + this.showAuthorAvatarToolStripMenuItem, this.showAuthorToolStripMenuItem, this.showAuthorDateToolStripMenuItem, this.showAuthorTimeToolStripMenuItem, @@ -575,6 +577,13 @@ private void InitializeComponent() this.showOriginalFilePathToolStripMenuItem.Text = "Show original file path"; this.showOriginalFilePathToolStripMenuItem.Click += new System.EventHandler(this.showOriginalFilePathToolStripMenuItem_Click); // + // showAuthorAvatarToolStripMenuItem + // + this.showAuthorAvatarToolStripMenuItem.Name = "showAuthorAvatarToolStripMenuItem"; + this.showAuthorAvatarToolStripMenuItem.Size = new System.Drawing.Size(247, 22); + this.showAuthorAvatarToolStripMenuItem.Text = "Show author avatar"; + this.showAuthorAvatarToolStripMenuItem.Click += new System.EventHandler(this.showAuthorAvatarToolStripMenuItem_Click); + // // FormFileHistory // this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); @@ -661,5 +670,6 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem displaySettingsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem showAuthorToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem showOriginalFilePathToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem showAuthorAvatarToolStripMenuItem; } -} \ No newline at end of file +} diff --git a/GitUI/CommandsDialogs/FormFileHistory.cs b/GitUI/CommandsDialogs/FormFileHistory.cs index ede5e21780b..89b81672164 100644 --- a/GitUI/CommandsDialogs/FormFileHistory.cs +++ b/GitUI/CommandsDialogs/FormFileHistory.cs @@ -135,6 +135,7 @@ public FormFileHistory(GitUICommands commands, string fileName, GitRevision revi detectMoveAndCopyInAllFilesToolStripMenuItem.Checked = AppSettings.DetectCopyInAllOnBlame; detectMoveAndCopyInThisFileToolStripMenuItem.Checked = AppSettings.DetectCopyInFileOnBlame; displayAuthorFirstToolStripMenuItem.Checked = AppSettings.BlameDisplayAuthorFirst; + showAuthorAvatarToolStripMenuItem.Checked = AppSettings.BlameShowAuthorAvatar; showAuthorToolStripMenuItem.Checked = AppSettings.BlameShowAuthor; showAuthorDateToolStripMenuItem.Checked = AppSettings.BlameShowAuthorDate; showAuthorTimeToolStripMenuItem.Checked = AppSettings.BlameShowAuthorTime; @@ -704,5 +705,12 @@ private void showOriginalFilePathToolStripMenuItem_Click(object sender, EventArg showOriginalFilePathToolStripMenuItem.Checked = AppSettings.BlameShowOriginalFilePath; UpdateSelectedFileViewers(true); } + + private void showAuthorAvatarToolStripMenuItem_Click(object sender, EventArgs e) + { + AppSettings.BlameShowAuthorAvatar = !AppSettings.BlameShowAuthorAvatar; + showAuthorAvatarToolStripMenuItem.Checked = AppSettings.BlameShowAuthorAvatar; + UpdateSelectedFileViewers(true); + } } } diff --git a/GitUI/Editor/BlameAuthorMargin.cs b/GitUI/Editor/BlameAuthorMargin.cs new file mode 100644 index 00000000000..bc75490a2a7 --- /dev/null +++ b/GitUI/Editor/BlameAuthorMargin.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using GitExtUtils.GitUI; +using ICSharpCode.TextEditor; +using Microsoft.VisualStudio.Threading; + +namespace GitUI.Editor +{ + public class GitBlameDisplay + { + public JoinableTask Avatar { get; set; } + public int RecencyIndex { get; set; } + public Color RecencyColor { get; set; } + } + + /// + /// This class display avatars in the gutter in a blame control. + /// + public class BlameAuthorMargin : AbstractMargin + { + private static readonly int RecencyIndexWidth = Convert.ToInt32(4 * DpiUtil.ScaleX); + private List _avatars; + private readonly int _lineHeight; + private readonly Color _backgroundColor; + private List _blameLines; + private readonly Dictionary _brushs = new Dictionary(); + private bool _isVisible = true; + + public BlameAuthorMargin(TextArea textArea) : base(textArea) + { + _lineHeight = GetFontHeight(textArea.Font); + _backgroundColor = ((SolidBrush)SystemBrushes.Window).Color; + Width = _lineHeight + RecencyIndexWidth + DpiUtil.Scale(2); + } + + private static int GetFontHeight(Font font) + { + var max = Math.Max( + TextRenderer.MeasureText("_", font).Height, + (int)Math.Ceiling(font.GetHeight())); + + return max + 1; + } + + public void Initialize(List blameLines) + { + _blameLines = blameLines; + _avatars = blameLines.Select(a => ThreadHelper.JoinableTaskFactory.Run(async () => await a.Avatar)).ToList(); + + // Update the resolution otherwise the image is not drawn at the good size :( + foreach (var avatar in _avatars) + { + if (avatar is Bitmap bitmapAvatar) + { + bitmapAvatar.SetResolution(DpiUtil.DpiX, DpiUtil.DpiY); + } + } + + // Build brushes + foreach (var blameLine in blameLines) + { + if (!_brushs.ContainsKey(blameLine.RecencyIndex)) + { + _brushs.Add(blameLine.RecencyIndex, new SolidBrush(blameLine.RecencyColor)); + } + } + } + + public override int Width { get; } + + public void SetVisiblity(bool isVisible) + { + _isVisible = isVisible; + } + + public override bool IsVisible => _isVisible; + + public override void Paint(Graphics g, Rectangle rect) + { + if (rect.Width <= 0 || rect.Height <= 0) + { + return; + } + + g.Clear(_backgroundColor); + + if (_avatars == null || _avatars.Count == 0) + { + return; + } + + var verticalOffset = textArea.VirtualTop.Y; + var lineStart = verticalOffset / _lineHeight; + var negativeOffset = (lineStart * _lineHeight) - verticalOffset; + var lineCount = (int)rect.Height / _lineHeight; + + for (int i = 0; i < lineCount; i++) + { + if (lineStart + i >= _avatars.Count) + { + break; + } + + int y = negativeOffset + (i * _lineHeight); + g.FillRectangle(_brushs[_blameLines[lineStart + i].RecencyIndex], 0, y, RecencyIndexWidth, _lineHeight); + + if (_avatars[lineStart + i] != null) + { + g.DrawImage(_avatars[lineStart + i], new Point(RecencyIndexWidth, y)); + } + } + + base.Paint(g, rect); + } + } +} diff --git a/GitUI/Editor/FileViewer.cs b/GitUI/Editor/FileViewer.cs index b542a9a3d5b..69432d22d24 100644 --- a/GitUI/Editor/FileViewer.cs +++ b/GitUI/Editor/FileViewer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Drawing; using System.IO; @@ -1618,5 +1619,10 @@ public bool ShowSyntaxHighlightingInDiff public ToolStripButton IgnoreAllWhitespacesButton => _fileViewer.ignoreAllWhitespaces; public ToolStripMenuItem IgnoreAllWhitespacesMenuItem => _fileViewer.ignoreAllWhitespaceChangesToolStripMenuItem; } + + public void SetGutterAvatars(List gitBlameDisplays) + { + internalFileViewer.SetGutterAvatars(gitBlameDisplays); + } } } diff --git a/GitUI/Editor/FileViewerInternal.cs b/GitUI/Editor/FileViewerInternal.cs index 51b214b55e3..bd020ea57dd 100644 --- a/GitUI/Editor/FileViewerInternal.cs +++ b/GitUI/Editor/FileViewerInternal.cs @@ -33,6 +33,7 @@ public partial class FileViewerInternal : GitModuleControl, IFileViewer private readonly CurrentViewPositionCache _currentViewPositionCache; private DiffViewerLineNumberControl _lineNumbersControl; private DiffHighlightService _diffHighlightService = DiffHighlightService.Instance; + private BlameAuthorMargin _authorsAvatarMargin; public FileViewerInternal() { @@ -646,5 +647,30 @@ public TestAccessor(FileViewerInternal control) public TextEditorControl TextEditor => _control.TextEditor; } + + public void SetGutterAvatars(List avatars) + { + if (!AppSettings.BlameShowAuthorAvatar) + { + if (_authorsAvatarMargin != null) + { + _authorsAvatarMargin.SetVisiblity(false); + } + + return; + } + + if (_authorsAvatarMargin == null) + { + _authorsAvatarMargin = new BlameAuthorMargin(TextEditor.ActiveTextAreaControl.TextArea); + TextEditor.ActiveTextAreaControl.TextArea.InsertLeftMargin(0, _authorsAvatarMargin); + } + else + { + _authorsAvatarMargin.SetVisiblity(true); + } + + _authorsAvatarMargin.Initialize(avatars); + } } } diff --git a/GitUI/Translation/English.xlf b/GitUI/Translation/English.xlf index bc9df99e88e..cec57db127a 100644 --- a/GitUI/Translation/English.xlf +++ b/GitUI/Translation/English.xlf @@ -4288,6 +4288,10 @@ If you are not sure just close this window. Save as + + Show author avatar + + Show author date diff --git a/GitUI/UserControls/BlameControl.cs b/GitUI/UserControls/BlameControl.cs index 6dd4547c10c..0dc1eb4708e 100644 --- a/GitUI/UserControls/BlameControl.cs +++ b/GitUI/UserControls/BlameControl.cs @@ -2,19 +2,25 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.Drawing.Imaging; using System.Globalization; using System.Linq; using System.Text; +using System.Threading.Tasks; using System.Windows.Forms; using GitCommands; using GitExtUtils; +using GitUI.Avatars; using GitUI.BranchTreePanel; using GitUI.CommitInfo; using GitUI.Editor; using GitUI.HelperDialogs; +using GitUI.Properties; using GitUIPluginInterfaces; using GitUIPluginInterfaces.RepositoryHosts; +using ICSharpCode.TextEditor; using JetBrains.Annotations; +using Microsoft.VisualStudio.Threading; using ResourceManager; namespace GitUI.Blame @@ -283,7 +289,10 @@ private void BlameFile_VScrollPositionChanged(object sender, EventArgs e) private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList children, Control controlToMask, int lineNumber, int scrollpos) { - var (gutter, body) = BuildBlameContents(filename); + var avatarSize = BlameAuthor.Font.Height + 1; + var (gutter, body, avatars) = BuildBlameContents(filename, avatarSize); + + BlameAuthor.SetGutterAvatars(avatars); ThreadHelper.JoinableTaskFactory.RunAsync( () => BlameAuthor.ViewTextAsync("committer.txt", gutter)); @@ -307,12 +316,15 @@ private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList gitBlameDisplays) BuildBlameContents(string filename, + int avatarSize) { var body = new StringBuilder(capacity: 4096); GitBlameCommit lastCommit = null; + var gitBlameDisplays = CalculateBlameRecencyData(); + var dateTimeFormat = AppSettings.BlameShowAuthorTime ? CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " " + CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern @@ -332,14 +344,47 @@ private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList>(); + var noAuthorTask = ThreadHelper.JoinableTaskFactory.RunAsync(() => Task.FromResult((Image)new Bitmap(Images.User80, avatarSize, avatarSize))); + var noAvatar = ThreadHelper.JoinableTaskFactory.RunAsync(() => Task.FromResult(null)); + for (var index = 0; index < _blame.Lines.Count; index++) { + var line = _blame.Lines[index]; if (line.Commit == lastCommit) { + if (gitBlameDisplays != null) + { + gitBlameDisplays[index].Avatar = noAvatar; + } + gutter.AppendLine(emptyLine); } else { + var authorEmail = line.Commit.AuthorMail?.Trim('<', '>'); + if (gitBlameDisplays != null) + { + if (authorEmail != null) + { + if (cacheAvatars.ContainsKey(authorEmail)) + { + gitBlameDisplays[index].Avatar = cacheAvatars[authorEmail]; + } + else + { + var avatar = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + await AvatarService.Default.GetAvatarAsync(authorEmail, line.Commit.Author, + avatarSize)); + cacheAvatars.Add(authorEmail, avatar); + gitBlameDisplays[index].Avatar = avatar; + } + } + else + { + gitBlameDisplays[index].Avatar = noAuthorTask; + } + } + BuildAuthorLine(line, lineBuilder, dateTimeFormat, filename, AppSettings.BlameShowAuthor, AppSettings.BlameShowAuthorDate, AppSettings.BlameShowOriginalFilePath, AppSettings.BlameDisplayAuthorFirst); gutter.Append(lineBuilder); @@ -352,10 +397,11 @@ private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList GetGradients(Color start, Color end, int steps = BlameGradientCount) + { + var stepA = (end.A - start.A) / (steps - 1); + var stepR = (end.R - start.R) / (steps - 1); + var stepG = (end.G - start.G) / (steps - 1); + var stepB = (end.B - start.B) / (steps - 1); + + for (int i = 0; i < steps; i++) + { + yield return Color.FromArgb(start.A + (stepA * i), + start.R + (stepR * i), + start.G + (stepG * i), + start.B + (stepB * i)); + } + } + + private List CalculateBlameRecencyData() + { + if (!AppSettings.BlameShowAuthorAvatar) + { + return null; + } + + var gitBlameDisplays = new List(_blame.Lines.Count); + var blameLines = _blame.Lines; + + var colors = GetGradients(Color.LightYellow, Color.Coral).ToList(); + var mostRecentDate = DateTime.Now.Ticks; + var lessRecentDate = Math.Min(DateTime.Now.AddYears(3).Ticks, blameLines.Select(l => l.Commit.AuthorTime).Where(d => d != DateTime.MinValue).Min().Ticks); + var intervalSize = (mostRecentDate - lessRecentDate) / BlameGradientCount; + foreach (var blame in _blame.Lines) + { + var relativeTicks = Math.Max(0, blame.Commit.AuthorTime.Ticks - lessRecentDate - 1); + var recencyIndex = (int)(relativeTicks / intervalSize); + var gitBlameDisplay = new GitBlameDisplay { RecencyIndex = recencyIndex, RecencyColor = colors[recencyIndex] }; + gitBlameDisplays.Add(gitBlameDisplay); + } + + return gitBlameDisplays; + } + private void ActiveTextAreaControlDoubleClick(object sender, EventArgs e) { if (_lastBlameLine == null) @@ -575,7 +663,7 @@ public GitBlame Blame public void BuildAuthorLine(GitBlameLine line, StringBuilder lineBuilder, string dateTimeFormat, string filename, bool showAuthor, bool showAuthorDate, bool showOriginalFilePath, bool displayAuthorFirst) => _control.BuildAuthorLine(line, lineBuilder, dateTimeFormat, filename, showAuthor, showAuthorDate, showOriginalFilePath, displayAuthorFirst); - public (string gutter, string body) BuildBlameContents(string filename) => _control.BuildBlameContents(filename); + public (string gutter, string body, List avatars) BuildBlameContents(string filename) => _control.BuildBlameContents(filename, 10); } } } diff --git a/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs b/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs index a0439514f1c..32af78fd0cd 100644 --- a/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs +++ b/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs @@ -102,7 +102,7 @@ public void BuildBlameContents_WithDateAndTime() { AppSettings.BlameShowAuthorTime = true; - var (gutter, content) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); + var (gutter, content, _) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); content.Should().Be($"line1{Environment.NewLine}line2{Environment.NewLine}line3{Environment.NewLine}line4{Environment.NewLine}"); var gutterLines = gutter.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); @@ -131,7 +131,7 @@ public void BuildBlameContents_WithDateButNotTime() AppSettings.BlameShowAuthorTime = false; // When - var (gutter, content) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); + var (gutter, content, _) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); // Then content.Should().Be($"line1{Environment.NewLine}line2{Environment.NewLine}line3{Environment.NewLine}line4{Environment.NewLine}");