using System; using System.Collections.Generic; using System.Globalization; using System.IO; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using AvaloniaEdit; using AvaloniaEdit.CodeCompletion; using AvaloniaEdit.Document; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; using AvaloniaEdit.Utils; namespace SourceGit.Views { public class CommitMessageCodeCompletionData : ICompletionData { public IImage Image { get => null; } public string Text { get; } public object Content { get => Text; } public object Description { get => null; } public double Priority { get => 0; } public CommitMessageCodeCompletionData(string text) { Text = text; } public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) { textArea.Document.Replace(completionSegment, Text); } } public class CommitMessageTextEditor : TextEditor { public static readonly StyledProperty CommitMessageProperty = AvaloniaProperty.Register(nameof(CommitMessage), string.Empty); public string CommitMessage { get => GetValue(CommitMessageProperty); set => SetValue(CommitMessageProperty, value); } public static readonly StyledProperty PlaceholderProperty = AvaloniaProperty.Register(nameof(Placeholder), string.Empty); public string Placeholder { get => GetValue(PlaceholderProperty); set => SetValue(PlaceholderProperty, value); } public static readonly StyledProperty ColumnProperty = AvaloniaProperty.Register(nameof(Column), 1); public int Column { get => GetValue(ColumnProperty); set => SetValue(ColumnProperty, value); } public static readonly StyledProperty SubjectLengthProperty = AvaloniaProperty.Register(nameof(SubjectLength)); public int SubjectLength { get => GetValue(SubjectLengthProperty); set => SetValue(SubjectLengthProperty, value); } public static readonly StyledProperty SubjectGuideLengthProperty = AvaloniaProperty.Register(nameof(SubjectGuideLength)); public int SubjectGuideLength { get => GetValue(SubjectGuideLengthProperty); set => SetValue(SubjectGuideLengthProperty, value); } public static readonly StyledProperty IsSubjectWarningIconVisibleProperty = AvaloniaProperty.Register(nameof(IsSubjectWarningIconVisible)); public bool IsSubjectWarningIconVisible { get => GetValue(IsSubjectWarningIconVisibleProperty); set => SetValue(IsSubjectWarningIconVisibleProperty, value); } public static readonly StyledProperty SubjectLineBrushProperty = AvaloniaProperty.Register(nameof(SubjectLineBrush), Brushes.Gray); public IBrush SubjectLineBrush { get => GetValue(SubjectLineBrushProperty); set => SetValue(SubjectLineBrushProperty, value); } protected override Type StyleKeyOverride => typeof(TextEditor); public CommitMessageTextEditor() : base(new TextArea(), new TextDocument()) { IsReadOnly = false; WordWrap = true; ShowLineNumbers = false; HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; VerticalScrollBarVisibility = ScrollBarVisibility.Auto; ClipToBounds = true; TextArea.TextView.Margin = new Thickness(4, 2); TextArea.TextView.ClipToBounds = false; TextArea.TextView.Options.EnableHyperlinks = false; TextArea.TextView.Options.EnableEmailHyperlinks = false; } public override void Render(DrawingContext context) { base.Render(context); var w = Bounds.Width; var pixelHeight = PixelSnapHelpers.GetPixelSize(this).Height; var pen = new Pen(SubjectLineBrush) { DashStyle = DashStyle.Dash }; if (SubjectLength == 0) { var placeholder = Placeholder; if (!string.IsNullOrEmpty(placeholder)) { var formatted = new FormattedText( Placeholder, CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(FontFamily), FontSize, Brushes.Gray); context.DrawText(formatted, new Point(4, 2)); } return; } if (TextArea.TextView is not { VisualLinesValid: true } view) return; var lines = new List(); foreach (var line in view.VisualLines) { if (line.IsDisposed || line.FirstDocumentLine == null || line.FirstDocumentLine.IsDeleted) continue; lines.Add(line); } if (lines.Count == 0) return; lines.Sort((l, r) => l.StartOffset - r.StartOffset); for (var i = 0; i < lines.Count; i++) { var line = lines[i]; if (line.FirstDocumentLine.LineNumber == _subjectEndLine) { var y = line.GetTextLineVisualYPosition(line.TextLines[^1], VisualYPosition.LineBottom) - view.VerticalOffset + 4; y = PixelSnapHelpers.PixelAlign(y, pixelHeight); context.DrawLine(pen, new Point(0, y), new Point(w, y)); var subjectEndTip = new FormattedText( "SUBJECT END", CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface(FontFamily, FontStyle.Italic), 10, Brushes.Gray); context.DrawText(subjectEndTip, new Point(w - subjectEndTip.WidthIncludingTrailingWhitespace - 6, y + 1)); return; } } } protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; TextArea.TextView.ContextRequested += OnTextViewContextRequested; TextArea.Caret.PositionChanged += OnCaretPositionChanged; } protected override void OnUnloaded(RoutedEventArgs e) { TextArea.TextView.ContextRequested -= OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; TextArea.Caret.PositionChanged -= OnCaretPositionChanged; base.OnUnloaded(e); } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == CommitMessageProperty) { if (!_isEditing) Text = CommitMessage; var lines = CommitMessage.ReplaceLineEndings("\n").Split('\n'); var subjectLen = 0; var foundSubjectEnd = false; for (var i = 0; i < lines.Length; i++) { var line = lines[i]; if (string.IsNullOrWhiteSpace(line)) { if (subjectLen == 0) continue; _subjectEndLine = i; foundSubjectEnd = true; break; } var validCharLen = line.TrimEnd().Length; if (subjectLen > 0) subjectLen += (validCharLen + 1); else subjectLen = validCharLen; } if (!foundSubjectEnd) _subjectEndLine = lines.Length; SetCurrentValue(SubjectLengthProperty, subjectLen); } else if (change.Property == PlaceholderProperty && IsLoaded) { if (string.IsNullOrWhiteSpace(CommitMessage)) InvalidateVisual(); } else if (change.Property == SubjectLengthProperty || change.Property == SubjectGuideLengthProperty) { SetCurrentValue(IsSubjectWarningIconVisibleProperty, SubjectLength > SubjectGuideLength); } } protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); if (!IsLoaded) return; _isEditing = true; SetCurrentValue(CommitMessageProperty, Text); _isEditing = false; var caretOffset = CaretOffset; var lineStart = caretOffset; for (; lineStart > 0; lineStart--) { var ch = Text[lineStart - 1]; if (ch == '\n') break; if (!char.IsAscii(ch)) return; } if (lineStart == 0 || caretOffset < lineStart + 2) { _completionWnd?.Close(); return; } var word = Text.Substring(lineStart, caretOffset - lineStart); var matches = new List(); foreach (var t in _trailers) { if (t.StartsWith(word, StringComparison.OrdinalIgnoreCase) && t.Length != word.Length) matches.Add(new(t)); } if (matches.Count > 0) { if (_completionWnd == null) { _completionWnd = new CompletionWindow(TextArea); _completionWnd.Closed += (_, _) => _completionWnd = null; _completionWnd.Show(); } _completionWnd.CompletionList.CompletionData.Clear(); _completionWnd.CompletionList.CompletionData.AddRange(matches); _completionWnd.StartOffset = lineStart; _completionWnd.EndOffset = caretOffset; } else { _completionWnd?.Close(); } } private void OnTextViewContextRequested(object sender, ContextRequestedEventArgs e) { var selection = TextArea.Selection; var hasSelected = selection is { IsEmpty: false }; var copy = new MenuItem(); copy.Header = App.Text("Copy"); copy.Icon = this.CreateMenuIcon("Icons.Copy"); copy.IsEnabled = hasSelected; copy.Click += (_, ev) => { Copy(); ev.Handled = true; }; var cut = new MenuItem(); cut.Header = App.Text("Cut"); cut.Icon = this.CreateMenuIcon("Icons.Cut"); cut.IsEnabled = hasSelected; cut.Click += (_, ev) => { Cut(); ev.Handled = true; }; var paste = new MenuItem(); paste.Header = App.Text("Paste"); paste.Icon = this.CreateMenuIcon("Icons.Paste"); paste.Click += (_, ev) => { Paste(); ev.Handled = true; }; var menu = new ContextMenu(); menu.Items.Add(copy); menu.Items.Add(cut); menu.Items.Add(paste); menu.Open(TextArea.TextView); e.Handled = true; } private void OnTextViewVisualLinesChanged(object sender, EventArgs e) { InvalidateVisual(); } private void OnCaretPositionChanged(object sender, EventArgs e) { var col = TextArea.Caret.Column; SetCurrentValue(ColumnProperty, col); } private readonly List _trailers = [ "Acked-by: ", "Assisted-by:", "BREAKING CHANGE: ", "Co-authored-by: ", "Fixes: ", "Helped-by: ", "Issue: ", "Milestone: ", "on-behalf-of: @", "Reference-to: ", "Refs: ", "Reviewed-by: ", "See-also: ", "Signed-off-by: ", ]; private bool _isEditing = false; private int _subjectEndLine = 0; private CompletionWindow _completionWnd = null; } public partial class CommitMessageToolBox : UserControl { public static readonly StyledProperty ShowAdvancedOptionsProperty = AvaloniaProperty.Register(nameof(ShowAdvancedOptions)); public bool ShowAdvancedOptions { get => GetValue(ShowAdvancedOptionsProperty); set => SetValue(ShowAdvancedOptionsProperty, value); } public static readonly StyledProperty CommitMessageProperty = AvaloniaProperty.Register(nameof(CommitMessage), string.Empty); public string CommitMessage { get => GetValue(CommitMessageProperty); set => SetValue(CommitMessageProperty, value); } public CommitMessageToolBox() { InitializeComponent(); } private async void OnOpenCommitMessagePicker(object sender, RoutedEventArgs e) { if (sender is Button button && DataContext is ViewModels.WorkingCopy vm && ShowAdvancedOptions) { var repo = vm.Repository; var foreground = this.FindResource("Brush.FG1") as IBrush; var menu = new ContextMenu(); menu.MaxWidth = 480; var gitTemplate = await new Commands.Config(repo.FullPath).GetAsync("commit.template"); var templateCount = repo.Settings.CommitTemplates.Count; if (templateCount == 0 && string.IsNullOrEmpty(gitTemplate)) { menu.Items.Add(new MenuItem() { Header = App.Text("WorkingCopy.NoCommitTemplates"), Icon = this.CreateMenuIcon("Icons.Code"), IsEnabled = false }); } else { for (int i = 0; i < templateCount; i++) { var icon = this.CreateMenuIcon("Icons.Code"); icon.Fill = foreground; var template = repo.Settings.CommitTemplates[i]; var item = new MenuItem(); item.Header = App.Text("WorkingCopy.UseCommitTemplate", template.Name); item.Icon = icon; item.Click += (_, ev) => { vm.ApplyCommitMessageTemplate(template); ev.Handled = true; }; menu.Items.Add(item); } if (!string.IsNullOrEmpty(gitTemplate)) { if (!Path.IsPathRooted(gitTemplate)) gitTemplate = Native.OS.GetAbsPath(repo.FullPath, gitTemplate); var friendlyName = gitTemplate; if (!OperatingSystem.IsWindows()) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var prefixLen = home.EndsWith('/') ? home.Length - 1 : home.Length; if (gitTemplate.StartsWith(home, StringComparison.Ordinal)) friendlyName = $"~{gitTemplate.AsSpan(prefixLen)}"; } var icon = this.CreateMenuIcon("Icons.Code"); icon.Fill = foreground; var gitTemplateItem = new MenuItem(); gitTemplateItem.Header = App.Text("WorkingCopy.UseCommitTemplate", friendlyName); gitTemplateItem.Icon = icon; gitTemplateItem.Click += (_, ev) => { if (File.Exists(gitTemplate)) vm.CommitMessage = File.ReadAllText(gitTemplate); ev.Handled = true; }; menu.Items.Add(gitTemplateItem); } } menu.Items.Add(new MenuItem() { Header = "-" }); var historiesCount = repo.Settings.CommitMessages.Count; if (historiesCount == 0) { menu.Items.Add(new MenuItem() { Header = App.Text("WorkingCopy.NoCommitHistories"), Icon = this.CreateMenuIcon("Icons.Histories"), IsEnabled = false }); } else { for (int i = 0; i < historiesCount; i++) { var dup = repo.Settings.CommitMessages[i].Trim(); var header = new TextBlock() { Text = dup.ReplaceLineEndings(" "), VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; var icon = this.CreateMenuIcon("Icons.Histories"); icon.Fill = foreground; var item = new MenuItem(); item.Header = header; item.Icon = icon; item.Click += (_, ev) => { vm.CommitMessage = dup; ev.Handled = true; }; menu.Items.Add(item); } menu.Items.Add(new MenuItem() { Header = "-" }); var clearIcon = this.CreateMenuIcon("Icons.Clear"); clearIcon.Fill = foreground; var clearHistoryItem = new MenuItem(); clearHistoryItem.Header = App.Text("WorkingCopy.ClearCommitHistories"); clearHistoryItem.Icon = clearIcon; clearHistoryItem.Click += async (_, ev) => { await vm.ClearCommitMessageHistoryAsync(); ev.Handled = true; }; menu.Items.Add(clearHistoryItem); } button.IsEnabled = false; menu.Placement = PlacementMode.TopEdgeAlignedLeft; menu.Closed += (_, _) => button.IsEnabled = true; menu.Open(button); } e.Handled = true; } private void OnOpenOpenAIHelper(object sender, RoutedEventArgs e) { if (DataContext is ViewModels.WorkingCopy vm && sender is Button button && ShowAdvancedOptions) { var repo = vm.Repository; if (vm.Staged == null || vm.Staged.Count == 0) { repo.SendNotification("No files added to commit!", true); e.Handled = true; return; } var services = repo.GetPreferredOpenAIServices(); if (services.Count == 0) { repo.SendNotification("Bad configuration for OpenAI", true); e.Handled = true; return; } if (services.Count == 1) { DoOpenAIAssistant(repo, services[0], vm.Staged); e.Handled = true; return; } var menu = new ContextMenu(); foreach (var service in services) { var dup = service; var item = new MenuItem(); item.Header = service.Name; item.Click += (_, ev) => { DoOpenAIAssistant(repo, dup, vm.Staged); ev.Handled = true; }; menu.Items.Add(item); } button.IsEnabled = false; menu.Placement = PlacementMode.TopEdgeAlignedLeft; menu.Closed += (_, _) => button.IsEnabled = true; menu.Open(button); } e.Handled = true; } private void OnOpenConventionalCommitHelper(object _, RoutedEventArgs e) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; var conventionalTypesOverride = owner switch { Launcher { DataContext: ViewModels.Launcher { ActivePage: { Data: ViewModels.Repository repo } } } => repo.Settings.ConventionalTypesOverride, RepositoryConfigure { DataContext: ViewModels.RepositoryConfigure config } => config.ConventionalTypesOverride, CommitMessageEditor editor => editor.ConventionalTypesOverride, _ => string.Empty }; var vm = new ViewModels.ConventionalCommitMessageBuilder(conventionalTypesOverride, text => CommitMessage = text); var builder = new ConventionalCommitMessageBuilder() { DataContext = vm }; builder.Show(owner); e.Handled = true; } private void DoOpenAIAssistant(ViewModels.Repository repo, AI.Service service, List changes) { var owner = TopLevel.GetTopLevel(this) as Window; if (owner == null) return; var assistant = new ViewModels.AIAssistant(repo, service, changes); var view = new AIAssistant() { DataContext = assistant }; view.Show(owner); } } }