From 463d161ac7166ba7799a73f0778788f679209b35 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 14 May 2025 17:55:28 +0800 Subject: [PATCH] refactor: show submodule as tree instead of list (#1307) --- src/ViewModels/Repository.cs | 9 +- src/ViewModels/SubmoduleTreeNode.cs | 206 ++++++++++++++++++++++++++++ src/Views/Repository.axaml | 104 ++------------ src/Views/Repository.axaml.cs | 57 +++----- src/Views/SubmodulesView.axaml | 120 ++++++++++++++++ src/Views/SubmodulesView.axaml.cs | 174 +++++++++++++++++++++++ 6 files changed, 534 insertions(+), 136 deletions(-) create mode 100644 src/ViewModels/SubmoduleTreeNode.cs create mode 100644 src/Views/SubmodulesView.axaml create mode 100644 src/Views/SubmodulesView.axaml.cs diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index b8ffc029..6571ed2c 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -210,7 +210,7 @@ namespace SourceGit.ViewModels private set => SetProperty(ref _submodules, value); } - public List VisibleSubmodules + public SubmoduleCollection VisibleSubmodules { get => _visibleSubmodules; private set => SetProperty(ref _visibleSubmodules, value); @@ -2512,7 +2512,7 @@ namespace SourceGit.ViewModels return visible; } - private List BuildVisibleSubmodules() + private SubmoduleCollection BuildVisibleSubmodules() { var visible = new List(); if (string.IsNullOrEmpty(_filter)) @@ -2527,7 +2527,8 @@ namespace SourceGit.ViewModels visible.Add(s); } } - return visible; + + return SubmoduleCollection.Build(visible, _visibleSubmodules); } private void RefreshHistoriesFilters(bool refresh) @@ -2759,7 +2760,7 @@ namespace SourceGit.ViewModels private List _tags = new List(); private List _visibleTags = new List(); private List _submodules = new List(); - private List _visibleSubmodules = new List(); + private SubmoduleCollection _visibleSubmodules = new SubmoduleCollection(); private bool _isAutoFetching = false; private Timer _autoFetchTimer = null; diff --git a/src/ViewModels/SubmoduleTreeNode.cs b/src/ViewModels/SubmoduleTreeNode.cs new file mode 100644 index 00000000..eea031bf --- /dev/null +++ b/src/ViewModels/SubmoduleTreeNode.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; + +using Avalonia.Collections; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.ViewModels +{ + public class SubmoduleTreeNode : ObservableObject + { + public string FullPath { get; set; } = string.Empty; + public int Depth { get; private set; } = 0; + public Models.Submodule Module { get; private set; } = null; + public List Children { get; private set; } = []; + public int Counter = 0; + + public bool IsFolder + { + get => Module == null; + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + public string ChildCounter + { + get => Counter > 0 ? $"({Counter})" : string.Empty; + } + + public bool IsDirty + { + get => Module?.IsDirty ?? false; + } + + public SubmoduleTreeNode(Models.Submodule module, int depth) + { + FullPath = module.Path; + Depth = depth; + Module = module; + IsExpanded = false; + } + + public SubmoduleTreeNode(string path, int depth, bool isExpanded) + { + FullPath = path; + Depth = depth; + IsExpanded = isExpanded; + Counter = 1; + } + + public static List Build(IList submodules, HashSet expaneded) + { + var nodes = new List(); + var folders = new Dictionary(); + + foreach (var module in submodules) + { + var sepIdx = module.Path.IndexOf('/', StringComparison.Ordinal); + if (sepIdx == -1) + { + nodes.Add(new SubmoduleTreeNode(module, 0)); + } + else + { + SubmoduleTreeNode lastFolder = null; + int depth = 0; + + while (sepIdx != -1) + { + var folder = module.Path.Substring(0, sepIdx); + if (folders.TryGetValue(folder, out var value)) + { + lastFolder = value; + lastFolder.Counter++; + } + else if (lastFolder == null) + { + lastFolder = new SubmoduleTreeNode(folder, depth, expaneded.Contains(folder)); + folders.Add(folder, lastFolder); + InsertFolder(nodes, lastFolder); + } + else + { + var cur = new SubmoduleTreeNode(folder, depth, expaneded.Contains(folder)); + folders.Add(folder, cur); + InsertFolder(lastFolder.Children, cur); + lastFolder = cur; + } + + depth++; + sepIdx = module.Path.IndexOf('/', sepIdx + 1); + } + + lastFolder?.Children.Add(new SubmoduleTreeNode(module, depth)); + } + } + + folders.Clear(); + return nodes; + } + + private static void InsertFolder(List collection, SubmoduleTreeNode subFolder) + { + for (int i = 0; i < collection.Count; i++) + { + if (!collection[i].IsFolder) + { + collection.Insert(i, subFolder); + return; + } + } + + collection.Add(subFolder); + } + + private bool _isExpanded = false; + } + + public class SubmoduleCollection + { + public List Tree + { + get; + set; + } = []; + + public AvaloniaList Rows + { + get; + set; + } = []; + + public static SubmoduleCollection Build(List submodules, SubmoduleCollection old) + { + var oldExpanded = new HashSet(); + foreach (var row in old.Rows) + { + if (row.IsFolder && row.IsExpanded) + oldExpanded.Add(row.FullPath); + } + + var collection = new SubmoduleCollection(); + collection.Tree = SubmoduleTreeNode.Build(submodules, oldExpanded); + + var rows = new List(); + collection.MakeTreeRows(rows, collection.Tree); + collection.Rows.AddRange(rows); + + return collection; + } + + public void Clear() + { + Tree.Clear(); + Rows.Clear(); + } + + public void ToggleExpand(SubmoduleTreeNode node) + { + node.IsExpanded = !node.IsExpanded; + + var rows = Rows; + var depth = node.Depth; + var idx = rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subrows = new List(); + MakeTreeRows(subrows, node.Children); + rows.InsertRange(idx + 1, subrows); + } + else + { + var removeCount = 0; + for (int i = idx + 1; i < rows.Count; i++) + { + var row = rows[i]; + if (row.Depth <= depth) + break; + + removeCount++; + } + rows.RemoveRange(idx + 1, removeCount); + } + } + + 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); + } + } + } +} diff --git a/src/Views/Repository.axaml b/src/Views/Repository.axaml index f841cac7..a600db56 100644 --- a/src/Views/Repository.axaml +++ b/src/Views/Repository.axaml @@ -330,100 +330,14 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -461,7 +375,7 @@ SelectionMode="Single" ContextRequested="OnWorktreeContextRequested" DoubleTapped="OnDoubleTappedWorktree" - PropertyChanged="OnLeftSidebarListBoxPropertyChanged" + PropertyChanged="OnWorktreeListPropertyChanged" IsVisible="{Binding IsWorktreeGroupExpanded, Mode=OneWay}"> + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/SubmodulesView.axaml.cs b/src/Views/SubmodulesView.axaml.cs new file mode 100644 index 00000000..116cbe9f --- /dev/null +++ b/src/Views/SubmodulesView.axaml.cs @@ -0,0 +1,174 @@ +using System; + +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 SubmoduleTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is ViewModels.SubmoduleTreeNode { IsFolder: true } node) + { + var view = this.FindAncestorOfType(); + view?.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class SubmoduleTreeNodeIcon : UserControl + { + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded)); + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IsExpandedProperty) + UpdateContent(); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + UpdateContent(); + } + + private void UpdateContent() + { + if (DataContext is not ViewModels.SubmoduleTreeNode node) + { + Content = null; + return; + } + + if (node.Module != null) + CreateContent(new Thickness(0, 0, 0, 0), "Icons.Submodule"); + 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 SubmodulesView : UserControl + { + public static readonly StyledProperty SubmodulesProperty = + AvaloniaProperty.Register(nameof(Submodules)); + + public ViewModels.SubmoduleCollection Submodules + { + get => GetValue(SubmodulesProperty); + set => SetValue(SubmodulesProperty, 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 SubmodulesView() + { + InitializeComponent(); + } + + public void ToggleNodeIsExpanded(ViewModels.SubmoduleTreeNode node) + { + var submodules = Submodules; + if (submodules != null) + { + submodules.ToggleExpand(node); + Rows = submodules.Rows.Count; + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == SubmodulesProperty) + { + Rows = Submodules?.Rows.Count ?? 0; + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + else if (change.Property == IsVisibleProperty) + { + RaiseEvent(new RoutedEventArgs(RowsChangedEvent)); + } + } + + private void OnDoubleTappedNode(object sender, TappedEventArgs e) + { + if (sender is Control { DataContext: ViewModels.SubmoduleTreeNode node } && + DataContext is ViewModels.Repository repo) + { + if (node.IsFolder) + ToggleNodeIsExpanded(node); + else if (node.Module.Status != Models.SubmoduleStatus.NotInited) + repo.OpenSubmodule(node.Module.Path); + } + + e.Handled = true; + } + + private void OnRowContextRequested(object sender, ContextRequestedEventArgs e) + { + if (sender is Control { DataContext: ViewModels.SubmoduleTreeNode node } control && + node.Module != null && + DataContext is ViewModels.Repository repo) + { + var menu = repo.CreateContextMenuForSubmodule(node.Module); + menu?.Open(control); + } + + e.Handled = true; + } + } +}