diff --git a/src/Resources/Styles.axaml b/src/Resources/Styles.axaml index 54addac8..78f3bbca 100644 --- a/src/Resources/Styles.axaml +++ b/src/Resources/Styles.axaml @@ -1070,15 +1070,15 @@ + + + + + + + + + + + + + diff --git a/src/Views/RevisionFileTreeView.axaml.cs b/src/Views/RevisionFileTreeView.axaml.cs new file mode 100644 index 00000000..6a026276 --- /dev/null +++ b/src/Views/RevisionFileTreeView.axaml.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +using CommunityToolkit.Mvvm.ComponentModel; + +namespace SourceGit.Views +{ + public class RevisionFileTreeNode : ObservableObject + { + public Models.Object Backend { get; set; } = null; + public int Depth { get; set; } = 0; + public List Children { get; set; } = new List(); + + public string Name + { + get => Backend == null ? string.Empty : Path.GetFileName(Backend.Path); + } + + public bool IsFolder + { + get => Backend != null && Backend.Type == Models.ObjectType.Tree; + } + + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + + private bool _isExpanded = false; + } + + public class RevisionFileTreeNodeToggleButton : ToggleButton + { + protected override Type StyleKeyOverride => typeof(ToggleButton); + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && + DataContext is RevisionFileTreeNode { IsFolder: true } node) + { + var tree = this.FindAncestorOfType(); + tree.ToggleNodeIsExpanded(node); + } + + e.Handled = true; + } + } + + public class RevisionTreeNodeIcon : UserControl + { + public static readonly StyledProperty NodeProperty = + AvaloniaProperty.Register(nameof(Node)); + + public RevisionFileTreeNode 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 RevisionTreeNodeIcon() + { + NodeProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + IsExpandedProperty.Changed.AddClassHandler((icon, _) => icon.UpdateContent()); + } + + private void UpdateContent() + { + var node = Node; + if (node == null || node.Backend == null) + { + Content = null; + return; + } + + var obj = node.Backend; + if (obj.Type == Models.ObjectType.Blob) + { + CreateContent(14, new Thickness(0, 0, 0, 0), "Icons.File"); + } + else if (obj.Type == Models.ObjectType.Commit) + { + CreateContent(14, new Thickness(0, 0, 0, 0), "Icons.Submodule"); + } + else + { + if (node.IsExpanded) + CreateContent(14, new Thickness(0, 2, 0, 0), "Icons.Folder.Open", Brushes.Goldenrod); + else + CreateContent(14, new Thickness(0, 2, 0, 0), "Icons.Folder.Fill", Brushes.Goldenrod); + } + } + + private void CreateContent(double size, Thickness margin, string iconKey, IBrush fill = null) + { + var geo = this.FindResource(iconKey) as StreamGeometry; + if (geo == null) + return; + + var icon = new Avalonia.Controls.Shapes.Path() + { + Width = size, + Height = size, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Margin = margin, + Data = geo, + }; + + if (fill != null) + icon.Fill = fill; + + Content = icon; + } + } + + public partial class RevisionFileTreeView : UserControl + { + public static readonly StyledProperty RevisionProperty = + AvaloniaProperty.Register(nameof(Revision), null); + + public string Revision + { + get => GetValue(RevisionProperty); + set => SetValue(RevisionProperty, value); + } + + public AvaloniaList Rows + { + get => _rows; + } + + public RevisionFileTreeView() + { + InitializeComponent(); + } + + public void ToggleNodeIsExpanded(RevisionFileTreeNode node) + { + _disableSelectionChangingEvent = true; + node.IsExpanded = !node.IsExpanded; + + var depth = node.Depth; + var idx = _rows.IndexOf(node); + if (idx == -1) + return; + + if (node.IsExpanded) + { + var subtree = GetChildrenOfTreeNode(node); + if (subtree != null && subtree.Count > 0) + { + var subrows = new List(); + MakeRows(subrows, node.Children, depth + 1); + _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); + } + + _disableSelectionChangingEvent = false; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == RevisionProperty) + { + _tree.Clear(); + _rows.Clear(); + + var vm = DataContext as ViewModels.CommitDetail; + if (vm == null || vm.Commit == null) + { + GC.Collect(); + return; + } + + var objects = vm.GetRevisionFilesUnderFolder(null); + if (objects == null || objects.Count == 0) + { + GC.Collect(); + return; + } + + foreach (var obj in objects) + _tree.Add(new RevisionFileTreeNode { Backend = obj }); + + _tree.Sort((l, r) => + { + if (l.IsFolder == r.IsFolder) + return l.Name.CompareTo(r.Name); + return l.IsFolder ? -1 : 1; + }); + + var topTree = new List(); + MakeRows(topTree, _tree, 0); + _rows.AddRange(topTree); + GC.Collect(); + } + } + + private void OnTreeNodeContextRequested(object sender, ContextRequestedEventArgs e) + { + if (DataContext is ViewModels.CommitDetail vm && sender is Grid { DataContext: RevisionFileTreeNode { Backend: Models.Object obj } } grid) + { + if (obj.Type != Models.ObjectType.Tree) + { + var menu = vm.CreateRevisionFileContextMenu(obj); + grid.OpenContextMenu(menu); + } + } + + e.Handled = true; + } + + private void OnTreeNodeDoubleTapped(object sender, TappedEventArgs e) + { + if (sender is Grid { DataContext: RevisionFileTreeNode { IsFolder: true } node }) + { + var posX = e.GetPosition(this).X; + if (posX < node.Depth * 16 + 16) + return; + + ToggleNodeIsExpanded(node); + } + } + + private void OnRowsSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_disableSelectionChangingEvent) + return; + + if (sender is ListBox list && DataContext is ViewModels.CommitDetail vm) + { + var node = list.SelectedItem as RevisionFileTreeNode; + if (node != null && !node.IsFolder) + vm.ViewRevisionFile(node.Backend); + else + vm.ViewRevisionFile(null); + } + } + + private List GetChildrenOfTreeNode(RevisionFileTreeNode node) + { + if (!node.IsFolder) + return null; + + if (node.Children.Count > 0) + return node.Children; + + var vm = DataContext as ViewModels.CommitDetail; + if (vm == null) + return null; + + var objects = vm.GetRevisionFilesUnderFolder(node.Backend.Path + "/"); + if (objects == null || objects.Count == 0) + return null; + + foreach (var obj in objects) + node.Children.Add(new RevisionFileTreeNode() { Backend = obj }); + + node.Children.Sort((l, r) => + { + if (l.IsFolder == r.IsFolder) + return l.Name.CompareTo(r.Name); + return l.IsFolder ? -1 : 1; + }); + + return node.Children; + } + + private void MakeRows(List rows, List nodes, int depth) + { + foreach (var node in nodes) + { + node.Depth = depth; + rows.Add(node); + + if (!node.IsExpanded || !node.IsFolder) + continue; + + MakeRows(rows, node.Children, depth + 1); + } + } + + private List _tree = new List(); + private AvaloniaList _rows = new AvaloniaList(); + private bool _disableSelectionChangingEvent = false; + } +} diff --git a/src/Views/RevisionFiles.axaml b/src/Views/RevisionFiles.axaml index 82a06b9f..0004ddb3 100644 --- a/src/Views/RevisionFiles.axaml +++ b/src/Views/RevisionFiles.axaml @@ -17,17 +17,7 @@ - - - - - - - - - - - + Children { get; set; } = new List(); - - public bool IsFolder => Backend != null && Backend.Type == Models.ObjectType.Tree; - public string Name => Backend != null ? Path.GetFileName(Backend.Path) : string.Empty; - } - - public class RevisionFileTreeView : UserControl - { - public static readonly StyledProperty RevisionProperty = - AvaloniaProperty.Register(nameof(Revision), null); - - public string Revision - { - get => GetValue(RevisionProperty); - set => SetValue(RevisionProperty, value); - } - - public Models.Object SelectedObject - { - get; - private set; - } = null; - - protected override Type StyleKeyOverride => typeof(UserControl); - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - - if (change.Property == RevisionProperty) - { - SelectedObject = null; - - if (Content is TreeDataGrid tree && tree.Source is IDisposable disposable) - disposable.Dispose(); - - var vm = DataContext as ViewModels.CommitDetail; - if (vm == null || vm.Commit == null) - { - Content = null; - GC.Collect(); - return; - } - - var objects = vm.GetRevisionFilesUnderFolder(null); - if (objects == null || objects.Count == 0) - { - Content = null; - GC.Collect(); - return; - } - - var toplevelObjects = new List(); - foreach (var obj in objects) - toplevelObjects.Add(new RevisionFileTreeNode() { Backend = obj }); - - toplevelObjects.Sort((l, r) => - { - if (l.IsFolder == r.IsFolder) - return l.Name.CompareTo(r.Name); - return l.IsFolder ? -1 : 1; - }); - - var template = this.FindResource("RevisionFileTreeNodeTemplate") as IDataTemplate; - var source = new HierarchicalTreeDataGridSource(toplevelObjects) - { - Columns = - { - new HierarchicalExpanderColumn( - new TemplateColumn(null, template, null, GridLength.Auto), - GetChildrenOfTreeNode, - x => x.IsFolder, - x => x.IsExpanded) - } - }; - - var selection = new Models.TreeDataGridSelectionModel(source, GetChildrenOfTreeNode); - selection.SingleSelect = true; - selection.SelectionChanged += (s, _) => - { - if (s is Models.TreeDataGridSelectionModel model) - { - var node = model.SelectedItem; - var detail = DataContext as ViewModels.CommitDetail; - - if (node != null && !node.IsFolder) - { - SelectedObject = node.Backend; - detail.ViewRevisionFile(node.Backend); - } - else - { - SelectedObject = null; - detail.ViewRevisionFile(null); - } - } - }; - - source.Selection = selection; - Content = new TreeDataGrid() - { - AutoDragDropRows = false, - ShowColumnHeaders = false, - CanUserResizeColumns = false, - CanUserSortColumns = false, - Source = source, - }; - - GC.Collect(); - } - } - - private List GetChildrenOfTreeNode(RevisionFileTreeNode node) - { - if (!node.IsFolder) - return null; - - if (node.Children.Count > 0) - return node.Children; - - var vm = DataContext as ViewModels.CommitDetail; - if (vm == null) - return null; - - var objects = vm.GetRevisionFilesUnderFolder(node.Backend.Path + "/"); - if (objects == null || objects.Count == 0) - return null; - - foreach (var obj in objects) - node.Children.Add(new RevisionFileTreeNode() { Backend = obj }); - - node.Children.Sort((l, r) => - { - if (l.IsFolder == r.IsFolder) - return l.Name.CompareTo(r.Name); - return l.IsFolder ? -1 : 1; - }); - - return node.Children; - } - } - public class RevisionTextFileView : TextEditor { protected override Type StyleKeyOverride => typeof(TextEditor); @@ -241,19 +91,5 @@ namespace SourceGit.Views { InitializeComponent(); } - - private void OnRevisionFileTreeViewContextRequested(object sender, ContextRequestedEventArgs e) - { - if (DataContext is ViewModels.CommitDetail vm && sender is RevisionFileTreeView view) - { - if (view.SelectedObject != null && view.SelectedObject.Type != Models.ObjectType.Tree) - { - var menu = vm.CreateRevisionFileContextMenu(view.SelectedObject); - view.OpenContextMenu(menu); - } - } - - e.Handled = true; - } } }