/
vasiukoff
/
sg
Обзор
Документация
Войти
/
vasiukoff
/
sg
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
src/Views/CommitMessageToolBox.axaml.cs
553 строки
19 KB
leo
feature: add `on-behalf-of:` keyword auto-completion
07 ноя 2025, 11:30
07 ноя 2025, 11:30
2fdff53
Код
Авторство
О чём код?
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<string> CommitMessageProperty = AvaloniaProperty.Register<CommitMessageTextEditor, string>(nameof(CommitMessage), string.Empty); public string CommitMessage { get => GetValue(CommitMessageProperty); set => SetValue(CommitMessageProperty, value); } public static readonly StyledProperty<string> PlaceholderProperty = AvaloniaProperty.Register<CommitMessageTextEditor, string>(nameof(Placeholder), string.Empty); public string Placeholder { get => GetValue(PlaceholderProperty); set => SetValue(PlaceholderProperty, value); } public static readonly StyledProperty<int> SubjectLengthProperty = AvaloniaProperty.Register<CommitMessageTextEditor, int>(nameof(SubjectLength), 0); public int SubjectLength { get => GetValue(SubjectLengthProperty); set => SetValue(SubjectLengthProperty, value); } public static readonly StyledProperty<IBrush> SubjectLineBrushProperty = AvaloniaProperty.Register<CommitMessageTextEditor, IBrush>(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; TextArea.TextView.Margin = new Thickness(4, 2); TextArea.TextView.Options.EnableHyperlinks = false; TextArea.TextView.Options.EnableEmailHyperlinks = false; TextArea.TextView.Options.AllowScrollBelowDocument = false; } public override void Render(DrawingContext context) { base.Render(context); var w = Bounds.Width; 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)); var y = 6 + formatted.Height; context.DrawLine(pen, new Point(0, y), new Point(w, y)); } return; } if (TextArea.TextView is not { VisualLinesValid: true } view) return; var lines = new List<VisualLine>(); 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; context.DrawLine(pen, new Point(0, y), new Point(w, y)); return; } } } protected override void OnLoaded(RoutedEventArgs e) { base.OnLoaded(e); TextArea.TextView.VisualLinesChanged += OnTextViewVisualLinesChanged; TextArea.TextView.ContextRequested += OnTextViewContextRequested; } protected override void OnUnloaded(RoutedEventArgs e) { TextArea.TextView.ContextRequested -= OnTextViewContextRequested; TextArea.TextView.VisualLinesChanged -= OnTextViewVisualLinesChanged; 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(); } } protected override void OnTextChanged(EventArgs e) { base.OnTextChanged(e); if (!IsLoaded) return; _isEditing = true; SetCurrentValue(CommitMessageProperty, Text); _isEditing = false; var caretOffset = CaretOffset; var start = caretOffset; for (; start > 0; start--) { var ch = Text[start - 1]; if (ch == '\n') break; if (!char.IsAscii(ch)) return; } if (caretOffset < start + 2) { _completionWnd?.Close(); return; } var word = Text.Substring(start, caretOffset - start); var matches = new List<CommitMessageCodeCompletionData>(); foreach (var keyword in _keywords) { if (keyword.StartsWith(word, StringComparison.OrdinalIgnoreCase) && keyword.Length != word.Length) matches.Add(new(keyword)); } if (matches.Count > 0) { if (_completionWnd == null) { _completionWnd = new CompletionWindow(TextArea); _completionWnd.Closed += (_, ev) => _completionWnd = null; _completionWnd.Show(); } _completionWnd.CompletionList.CompletionData.Clear(); _completionWnd.CompletionList.CompletionData.AddRange(matches); _completionWnd.StartOffset = start; _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 = App.CreateMenuIcon("Icons.Copy"); copy.IsEnabled = hasSelected; copy.Click += (o, ev) => { Copy(); ev.Handled = true; }; var cut = new MenuItem(); cut.Header = App.Text("Cut"); cut.Icon = App.CreateMenuIcon("Icons.Cut"); cut.IsEnabled = hasSelected; cut.Click += (o, ev) => { Cut(); ev.Handled = true; }; var paste = new MenuItem(); paste.Header = App.Text("Paste"); paste.Icon = App.CreateMenuIcon("Icons.Paste"); paste.Click += (o, 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 readonly List<string> _keywords = ["Acked-by: ", "Co-authored-by: ", "Reviewed-by: ", "Signed-off-by: ", "on-behalf-of: @", "BREAKING CHANGE: ", "Refs: "]; private bool _isEditing = false; private int _subjectEndLine = 0; private CompletionWindow _completionWnd = null; } public partial class CommitMessageToolBox : UserControl { public static readonly StyledProperty<bool> ShowAdvancedOptionsProperty = AvaloniaProperty.Register<CommitMessageToolBox, bool>(nameof(ShowAdvancedOptions)); public bool ShowAdvancedOptions { get => GetValue(ShowAdvancedOptionsProperty); set => SetValue(ShowAdvancedOptionsProperty, value); } public static readonly StyledProperty<string> CommitMessageProperty = AvaloniaProperty.Register<CommitMessageToolBox, string>(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 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 = App.CreateMenuIcon("Icons.Code"), IsEnabled = false }); } else { for (int i = 0; i < templateCount; i++) { var template = repo.Settings.CommitTemplates[i]; var item = new MenuItem(); item.Header = App.Text("WorkingCopy.UseCommitTemplate", template.Name); item.Icon = App.CreateMenuIcon("Icons.Code"); 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 gitTemplateItem = new MenuItem(); gitTemplateItem.Header = App.Text("WorkingCopy.UseCommitTemplate", friendlyName); gitTemplateItem.Icon = App.CreateMenuIcon("Icons.Code"); 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 = App.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 item = new MenuItem(); item.Header = header; item.Icon = App.CreateMenuIcon("Icons.Histories"); item.Click += (_, ev) => { vm.CommitMessage = dup; ev.Handled = true; }; menu.Items.Add(item); } menu.Items.Add(new MenuItem() { Header = "-" }); var clearHistoryItem = new MenuItem(); clearHistoryItem.Header = App.Text("WorkingCopy.ClearCommitHistories"); clearHistoryItem.Icon = App.CreateMenuIcon("Icons.Clear"); clearHistoryItem.Click += async (_, ev) => { await vm.ClearCommitMessageHistoryAsync(); ev.Handled = true; }; menu.Items.Add(clearHistoryItem); } menu.Placement = PlacementMode.TopEdgeAlignedLeft; menu.Open(button); } e.Handled = true; } private async void OnOpenOpenAIHelper(object sender, RoutedEventArgs e) { if (DataContext is ViewModels.WorkingCopy vm && sender is Control control && ShowAdvancedOptions) { var repo = vm.Repository; if (vm.Staged == null || vm.Staged.Count == 0) { App.RaiseException(repo.FullPath, "No files added to commit!"); return; } var services = repo.GetPreferredOpenAIServices(); if (services.Count == 0) { App.RaiseException(repo.FullPath, "Bad configuration for OpenAI"); return; } if (services.Count == 1) { await App.ShowDialog(new ViewModels.AIAssistant(repo, services[0], vm.Staged, t => vm.CommitMessage = t)); return; } var menu = new ContextMenu() { Placement = PlacementMode.TopEdgeAlignedLeft }; foreach (var service in services) { var dup = service; var item = new MenuItem(); item.Header = service.Name; item.Click += async (_, ev) => { await App.ShowDialog(new ViewModels.AIAssistant(repo, dup, vm.Staged, t => vm.CommitMessage = t)); ev.Handled = true; }; menu.Items.Add(item); } menu.Open(control); } 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; } } }