From de2f70b8eafdc1ceaa6f5cf2f86c689d78912867 Mon Sep 17 00:00:00 2001 From: leo Date: Sun, 11 Aug 2024 18:12:58 +0800 Subject: [PATCH] feature: supports display tags in a tree (#350) --- src/Models/Watcher.cs | 2 +- src/Resources/Locales/en_US.axaml | 1 + src/Resources/Locales/zh_CN.axaml | 1 + src/Resources/Locales/zh_TW.axaml | 1 + src/Resources/Styles.axaml | 32 +++ src/ViewModels/Preference.cs | 7 + src/ViewModels/TagCollection.cs | 141 +++++++++++++ src/Views/Repository.axaml | 104 +++------- src/Views/Repository.axaml.cs | 49 ++--- src/Views/TagsView.axaml | 103 ++++++++++ src/Views/TagsView.axaml.cs | 324 ++++++++++++++++++++++++++++++ 11 files changed, 652 insertions(+), 113 deletions(-) create mode 100644 src/ViewModels/TagCollection.cs create mode 100644 src/Views/TagsView.axaml create mode 100644 src/Views/TagsView.axaml.cs diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index d10d8670..6cd77a15 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -198,7 +198,7 @@ namespace SourceGit.Models (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) { _updateBranch = DateTime.Now.AddSeconds(.5).ToFileTime(); - + lock (_submodules) { if (_submodules.Count > 0) diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 2c1e570e..364494f0 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -485,6 +485,7 @@ SHA Author & Committer Search Branches & Tags + Show Tags as Tree Statistics SUBMODULES ADD SUBMODULE diff --git a/src/Resources/Locales/zh_CN.axaml b/src/Resources/Locales/zh_CN.axaml index c0ef6b6e..58e915db 100644 --- a/src/Resources/Locales/zh_CN.axaml +++ b/src/Resources/Locales/zh_CN.axaml @@ -487,6 +487,7 @@ 提交指纹 作者及提交者 快速查找分支、标签 + 以树型结构展示 提交统计 子模块列表 添加子模块 diff --git a/src/Resources/Locales/zh_TW.axaml b/src/Resources/Locales/zh_TW.axaml index f991c912..167933da 100644 --- a/src/Resources/Locales/zh_TW.axaml +++ b/src/Resources/Locales/zh_TW.axaml @@ -487,6 +487,7 @@ 提交指紋 作者及提交者 快速查找分支、標籤 + 以樹型結構展示 提交統計 子模組列表 新增子模組 diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index ffe69765..f40e88f6 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -1257,6 +1257,38 @@ + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/Views/Repository.axaml.cs b/src/Views/Repository.axaml.cs index 0c9a106f..8b3eaac4 100644 --- a/src/Views/Repository.axaml.cs +++ b/src/Views/Repository.axaml.cs @@ -2,7 +2,6 @@ using System; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; @@ -30,6 +29,9 @@ namespace SourceGit.Views private void OnSearchKeyDown(object _, KeyEventArgs e) { var repo = DataContext as ViewModels.Repository; + if (repo == null) + return; + if (e.Key == Key.Enter) { if (!string.IsNullOrWhiteSpace(repo.SearchCommitFilter)) @@ -79,46 +81,25 @@ namespace SourceGit.Views private void OnLocalBranchTreeSelectionChanged(object _1, RoutedEventArgs _2) { RemoteBranchTree.UnselectAll(); - TagsList.SelectedItem = null; + TagsList.UnselectAll(); } private void OnRemoteBranchTreeSelectionChanged(object _1, RoutedEventArgs _2) { LocalBranchTree.UnselectAll(); - TagsList.SelectedItem = null; + TagsList.UnselectAll(); } - private void OnTagDataGridSelectionChanged(object sender, SelectionChangedEventArgs _) + private void OnTagsRowsChanged(object _, RoutedEventArgs e) { - if (sender is DataGrid { SelectedItem: Models.Tag tag }) - { - LocalBranchTree.UnselectAll(); - RemoteBranchTree.UnselectAll(); - - if (DataContext is ViewModels.Repository repo) - repo.NavigateToCommit(tag.SHA); - } - } - - private void OnTagContextRequested(object sender, ContextRequestedEventArgs e) - { - if (sender is DataGrid { SelectedItem: Models.Tag tag } grid && DataContext is ViewModels.Repository repo) - { - var menu = repo.CreateContextMenuForTag(tag); - grid.OpenContextMenu(menu); - } - + UpdateLeftSidebarLayout(); e.Handled = true; } - private void OnTagFilterIsCheckedChanged(object sender, RoutedEventArgs e) + private void OnTagsSelectionChanged(object _1, RoutedEventArgs _2) { - if (sender is ToggleButton { DataContext: Models.Tag tag } toggle && DataContext is ViewModels.Repository repo) - { - repo.UpdateFilter(tag.Name, toggle.IsChecked == true); - } - - e.Handled = true; + LocalBranchTree.UnselectAll(); + RemoteBranchTree.UnselectAll(); } private void OnSubmoduleContextRequested(object sender, ContextRequestedEventArgs e) @@ -188,7 +169,7 @@ namespace SourceGit.Views var localBranchRows = vm.IsLocalBranchGroupExpanded ? LocalBranchTree.Rows.Count : 0; var remoteBranchRows = vm.IsRemoteGroupExpanded ? RemoteBranchTree.Rows.Count : 0; var desiredBranches = (localBranchRows + remoteBranchRows) * 24.0; - var desiredTag = vm.IsTagGroupExpanded ? TagsList.RowHeight * vm.VisibleTags.Count : 0; + var desiredTag = vm.IsTagGroupExpanded ? 24.0 * TagsList.Rows : 0; var desiredSubmodule = vm.IsSubmoduleGroupExpanded ? SubmoduleList.RowHeight * vm.Submodules.Count : 0; var desiredWorktree = vm.IsWorktreeGroupExpanded ? WorktreeList.RowHeight * vm.Worktrees.Count : 0; var desiredOthers = desiredTag + desiredSubmodule + desiredWorktree; @@ -295,9 +276,12 @@ namespace SourceGit.Views } } - private void OnSearchSuggestionBoxKeyDown(object sender, KeyEventArgs e) + private void OnSearchSuggestionBoxKeyDown(object _, KeyEventArgs e) { var repo = DataContext as ViewModels.Repository; + if (repo == null) + return; + if (e.Key == Key.Escape) { repo.IsSearchCommitSuggestionOpen = false; @@ -317,6 +301,9 @@ namespace SourceGit.Views private void OnSearchSuggestionDoubleTapped(object sender, TappedEventArgs e) { var repo = DataContext as ViewModels.Repository; + if (repo == null) + return; + var content = (sender as StackPanel)?.DataContext as string; if (!string.IsNullOrEmpty(content)) { diff --git a/src/Views/TagsView.axaml b/src/Views/TagsView.axaml new file mode 100644 index 00000000..bcbbe358 --- /dev/null +++ b/src/Views/TagsView.axaml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/TagsView.axaml.cs b/src/Views/TagsView.axaml.cs new file mode 100644 index 00000000..23d31ab4 --- /dev/null +++ b/src/Views/TagsView.axaml.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SourceGit.Views +{ + public class TagTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.TagTreeNode { IsFolder: true } node) + { + var view = this.FindAncestorOfType(); + view?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class TagTreeNodeIcon : UserControl + { + public static readonly StyledProperty NodeProperty = + AvaloniaProperty.Register(nameof(Node)); + + public ViewModels.TagTreeNode Node + { + get => GetValue(NodeProperty); + set => SetValue(NodeProperty, value); + } + + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + static TagTreeNodeIcon() + { + NodeProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + IsExpandedProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + } + + private void UpdateContent() + { + var node = Node; + if (node == null) + { + Content = null; + return; + } + + if (node.Tag != null) + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Tag"); + else if (node.IsExpanded) + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder.Open"); + else + CreateContent(new Thickness(0, 2, 0, 0), "Icons.Folder"); + } + + private void CreateContent(Thickness margin, string iconKey) + { + var geo = this.FindResource(iconKey) as StreamGeometry; + if (geo == null) + return; + + Content = new Avalonia.Controls.Shapes.Path() + { + Width = 12, + Height = 12, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = margin, + Data = geo, + }; + } + } + + public partial class TagsView : UserControl + { + public static readonly StyledProperty ShowTagsAsTreeProperty = + AvaloniaProperty.Register(nameof(ShowTagsAsTree)); + + public bool ShowTagsAsTree + { + get => GetValue(ShowTagsAsTreeProperty); + set => SetValue(ShowTagsAsTreeProperty, value); + } + + public static readonly StyledProperty> TagsProperty = + AvaloniaProperty.Register>(nameof(Tags)); + + public List Tags + { + get => GetValue(TagsProperty); + set => SetValue(TagsProperty, value); + } + + public static readonly RoutedEvent SelectionChangedEvent = + RoutedEvent.Register(nameof(SelectionChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler SelectionChanged + { + add { AddHandler(SelectionChangedEvent, value); } + remove { RemoveHandler(SelectionChangedEvent, value); } + } + + public static readonly RoutedEvent RowsChangedEvent = + RoutedEvent.Register(nameof(RowsChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + public event EventHandler RowsChanged + { + add { AddHandler(RowsChangedEvent, value); } + remove { RemoveHandler(RowsChangedEvent, value); } + } + + public int Rows + { + get; + private set; + } + + public TagsView() + { + InitializeComponent(); + } + + public void UnselectAll() + { + var list = this.FindDescendantOfType(); + if (list != null) + list.SelectedItem = null; + } + + public void ToggleNodeIsExpanded(ViewModels.TagTreeNode node) + { + if (Content is ViewModels.TagCollectionAsTree tree) + { + node.IsExpanded = !node.IsExpanded; + + var depth = node.Depth; + var idx = tree.Rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + MakeTreeRows(subrows, node.Children); + tree.Rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < tree.Rows.Count; i++) + { + var row = tree.Rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + tree.Rows.RemoveRange(idx + 1, removeCount); + } + + Rows = tree.Rows.Count; + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ShowTagsAsTreeProperty || change.Property == TagsProperty) + { + UpdateDataSource(); + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + else if (change.Property == IsVisibleProperty) + { + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + private void OnDoubleTappedNode(object sender, TappedEventArgs e) + { + if (sender is Grid { DataContext: ViewModels.TagTreeNode node }) + { + if (node.IsFolder) + ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + + private void OnRowContextRequested(object sender, ContextRequestedEventArgs e) + { + var control = sender as Control; + if (control == null) + return; + + Models.Tag selected; + if (control.DataContext is ViewModels.TagTreeNode node) + selected = node.Tag; + else if (control.DataContext is Models.Tag tag) + selected = tag; + else + selected = null; + + if (selected != null && DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForTag(selected); + control.OpenContextMenu(menu); + } + + e.Handled = true; + } + + private void OnRowSelectionChanged(object sender, SelectionChangedEventArgs _) + { + var selected = (sender as ListBox)?.SelectedItem; + var selectedTag = null as Models.Tag; + if (selected is ViewModels.TagTreeNode node) + selectedTag = node.Tag; + else if (selected is Models.Tag tag) + selectedTag = tag; + + if (selectedTag != null && DataContext is ViewModels.Repository repo) + { + RaiseEvent(new RoutedEventArgs(SelectionChangedEvent)); + repo.NavigateToCommit(selectedTag.SHA); + } + } + + private void OnToggleFilter(object sender, RoutedEventArgs e) + { + if (sender is ToggleButton toggle && DataContext is ViewModels.Repository repo) + { + var target = null as Models.Tag; + if (toggle.DataContext is ViewModels.TagTreeNode node) + target = node.Tag; + else if (toggle.DataContext is Models.Tag tag) + target = tag; + + if (target != null) + repo.UpdateFilter(target.Name, toggle.IsChecked == true); + } + + e.Handled = true; + } + + private void MakeTreeRows(List rows, List nodes) + { + foreach (var node in nodes) + { + rows.Add(node); + + if (!node.IsExpanded || !node.IsFolder) + continue; + + MakeTreeRows(rows, node.Children); + } + } + + private void UpdateDataSource() + { + var tags = Tags; + if (tags == null || tags.Count == 0) + { + Content = null; + return; + } + + if (ShowTagsAsTree) + { + var oldExpanded = new HashSet(); + if (Content is ViewModels.TagCollectionAsTree oldTree) + { + foreach (var row in oldTree.Rows) + { + if (row.IsFolder && row.IsExpanded) + oldExpanded.Add(row.FullPath); + } + } + + var tree = new ViewModels.TagCollectionAsTree(); + tree.Tree = ViewModels.TagTreeNode.Build(tags, oldExpanded); + + var rows = new List(); + MakeTreeRows(rows, tree.Tree); + tree.Rows.AddRange(rows); + + Content = tree; + Rows = rows.Count; + } + else + { + var list = new ViewModels.TagCollectionAsList(); + list.Tags.AddRange(tags); + + Content = list; + Rows = tags.Count; + } + + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } +} +