feature: supports display tags in a tree (#350)

This commit is contained in:
leo 2024-08-11 18:12:58 +08:00
parent f59af0afcf
commit de2f70b8ea
No known key found for this signature in database
11 changed files with 652 additions and 113 deletions

View file

@ -485,6 +485,7 @@
<x:String x:Key="Text.Repository.Search.BySHA" xml:space="preserve">SHA</x:String> <x:String x:Key="Text.Repository.Search.BySHA" xml:space="preserve">SHA</x:String>
<x:String x:Key="Text.Repository.Search.ByUser" xml:space="preserve">Author &amp; Committer</x:String> <x:String x:Key="Text.Repository.Search.ByUser" xml:space="preserve">Author &amp; Committer</x:String>
<x:String x:Key="Text.Repository.SearchBranchTag" xml:space="preserve">Search Branches &amp; Tags</x:String> <x:String x:Key="Text.Repository.SearchBranchTag" xml:space="preserve">Search Branches &amp; Tags</x:String>
<x:String x:Key="Text.Repository.ShowTagsAsTree" xml:space="preserve">Show Tags as Tree</x:String>
<x:String x:Key="Text.Repository.Statistics" xml:space="preserve">Statistics</x:String> <x:String x:Key="Text.Repository.Statistics" xml:space="preserve">Statistics</x:String>
<x:String x:Key="Text.Repository.Submodules" xml:space="preserve">SUBMODULES</x:String> <x:String x:Key="Text.Repository.Submodules" xml:space="preserve">SUBMODULES</x:String>
<x:String x:Key="Text.Repository.Submodules.Add" xml:space="preserve">ADD SUBMODULE</x:String> <x:String x:Key="Text.Repository.Submodules.Add" xml:space="preserve">ADD SUBMODULE</x:String>

View file

@ -487,6 +487,7 @@
<x:String x:Key="Text.Repository.Search.BySHA" xml:space="preserve">提交指纹</x:String> <x:String x:Key="Text.Repository.Search.BySHA" xml:space="preserve">提交指纹</x:String>
<x:String x:Key="Text.Repository.Search.ByUser" xml:space="preserve">作者及提交者</x:String> <x:String x:Key="Text.Repository.Search.ByUser" xml:space="preserve">作者及提交者</x:String>
<x:String x:Key="Text.Repository.SearchBranchTag" xml:space="preserve">快速查找分支、标签</x:String> <x:String x:Key="Text.Repository.SearchBranchTag" xml:space="preserve">快速查找分支、标签</x:String>
<x:String x:Key="Text.Repository.ShowTagsAsTree" xml:space="preserve">以树型结构展示</x:String>
<x:String x:Key="Text.Repository.Statistics" xml:space="preserve">提交统计</x:String> <x:String x:Key="Text.Repository.Statistics" xml:space="preserve">提交统计</x:String>
<x:String x:Key="Text.Repository.Submodules" xml:space="preserve">子模块列表</x:String> <x:String x:Key="Text.Repository.Submodules" xml:space="preserve">子模块列表</x:String>
<x:String x:Key="Text.Repository.Submodules.Add" xml:space="preserve">添加子模块</x:String> <x:String x:Key="Text.Repository.Submodules.Add" xml:space="preserve">添加子模块</x:String>

View file

@ -487,6 +487,7 @@
<x:String x:Key="Text.Repository.Search.BySHA" xml:space="preserve">提交指紋</x:String> <x:String x:Key="Text.Repository.Search.BySHA" xml:space="preserve">提交指紋</x:String>
<x:String x:Key="Text.Repository.Search.ByUser" xml:space="preserve">作者及提交者</x:String> <x:String x:Key="Text.Repository.Search.ByUser" xml:space="preserve">作者及提交者</x:String>
<x:String x:Key="Text.Repository.SearchBranchTag" xml:space="preserve">快速查找分支、標籤</x:String> <x:String x:Key="Text.Repository.SearchBranchTag" xml:space="preserve">快速查找分支、標籤</x:String>
<x:String x:Key="Text.Repository.ShowTagsAsTree" xml:space="preserve">以樹型結構展示</x:String>
<x:String x:Key="Text.Repository.Statistics" xml:space="preserve">提交統計</x:String> <x:String x:Key="Text.Repository.Statistics" xml:space="preserve">提交統計</x:String>
<x:String x:Key="Text.Repository.Submodules" xml:space="preserve">子模組列表</x:String> <x:String x:Key="Text.Repository.Submodules" xml:space="preserve">子模組列表</x:String>
<x:String x:Key="Text.Repository.Submodules.Add" xml:space="preserve">新增子模組</x:String> <x:String x:Key="Text.Repository.Submodules.Add" xml:space="preserve">新增子模組</x:String>

View file

@ -1257,6 +1257,38 @@
<Setter Property="Opacity" Value="1"/> <Setter Property="Opacity" Value="1"/>
</Style> </Style>
<Style Selector="ToggleButton.tag_display_mode">
<Setter Property="Margin" Value="0" />
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<ControlTemplate>
<Border Background="Transparent"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<Path x:Name="ChevronPath"
Width="11" Height="11"
Margin="0,1,0,0"
Data="{StaticResource Icons.Tree}"
Fill="{DynamicResource Brush.FG1}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Opacity="0.65"/>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:checked /template/ Path#ChevronPath">
<Setter Property="Fill" Value="{DynamicResource Brush.Accent}" />
</Style>
<Style Selector="^:pointerover /template/ Path#ChevronPath">
<Setter Property="Fill" Value="{DynamicResource Brush.Accent}" />
<Setter Property="Opacity" Value="1"/>
</Style>
</Style>
<Style Selector="Slider"> <Style Selector="Slider">
<Style.Resources> <Style.Resources>
<Thickness x:Key="SliderTopHeaderMargin">0,0,0,4</Thickness> <Thickness x:Key="SliderTopHeaderMargin">0,0,0,4</Thickness>

View file

@ -177,6 +177,12 @@ namespace SourceGit.ViewModels
set; set;
} = string.Empty; } = string.Empty;
public bool ShowTagsAsTree
{
get => _showTagsAsTree;
set => SetProperty(ref _showTagsAsTree, value);
}
public bool UseTwoColumnsLayoutInHistories public bool UseTwoColumnsLayoutInHistories
{ {
get => _useTwoColumnsLayoutInHistories; get => _useTwoColumnsLayoutInHistories;
@ -520,6 +526,7 @@ namespace SourceGit.ViewModels
private bool _useFixedTabWidth = true; private bool _useFixedTabWidth = true;
private bool _check4UpdatesOnStartup = true; private bool _check4UpdatesOnStartup = true;
private bool _showTagsAsTree = false;
private bool _useTwoColumnsLayoutInHistories = false; private bool _useTwoColumnsLayoutInHistories = false;
private bool _displayTimeAsPeriodInHistories = false; private bool _displayTimeAsPeriodInHistories = false;
private bool _useSideBySideDiff = false; private bool _useSideBySideDiff = false;

View file

@ -0,0 +1,141 @@
using System;
using System.Collections.Generic;
using Avalonia.Collections;
using CommunityToolkit.Mvvm.ComponentModel;
namespace SourceGit.ViewModels
{
public class TagTreeNode : ObservableObject
{
public string FullPath { get; set; }
public int Depth { get; private set; } = 0;
public Models.Tag Tag { get; private set; } = null;
public List<TagTreeNode> Children { get; private set; } = [];
public bool IsFolder
{
get => Tag == null;
}
public bool IsFiltered
{
get => Tag?.IsFiltered ?? false;
set
{
if (Tag != null)
Tag.IsFiltered = value;
}
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
public TagTreeNode(Models.Tag t, int depth)
{
FullPath = t.Name;
Depth = depth;
Tag = t;
IsExpanded = false;
}
public TagTreeNode(string path, bool isExpanded, int depth)
{
FullPath = path;
Depth = depth;
IsExpanded = isExpanded;
}
public static List<TagTreeNode> Build(IList<Models.Tag> tags, HashSet<string> expaneded)
{
var nodes = new List<TagTreeNode>();
var folders = new Dictionary<string, TagTreeNode>();
foreach (var tag in tags)
{
var sepIdx = tag.Name.IndexOf('/', StringComparison.Ordinal);
if (sepIdx == -1)
{
nodes.Add(new TagTreeNode(tag, 0));
}
else
{
TagTreeNode lastFolder = null;
int depth = 0;
while (sepIdx != -1)
{
var folder = tag.Name.Substring(0, sepIdx);
if (folders.TryGetValue(folder, out var value))
{
lastFolder = value;
}
else if (lastFolder == null)
{
lastFolder = new TagTreeNode(folder, expaneded.Contains(folder), depth);
folders.Add(folder, lastFolder);
InsertFolder(nodes, lastFolder);
}
else
{
var cur = new TagTreeNode(folder, expaneded.Contains(folder), depth);
folders.Add(folder, cur);
InsertFolder(lastFolder.Children, cur);
lastFolder = cur;
}
depth++;
sepIdx = tag.Name.IndexOf('/', sepIdx + 1);
}
lastFolder?.Children.Add(new TagTreeNode(tag, depth));
}
}
folders.Clear();
return nodes;
}
private static void InsertFolder(List<TagTreeNode> collection, TagTreeNode subFolder)
{
for (int i = 0; i < collection.Count; i++)
{
if (!collection[i].IsFolder)
{
collection.Insert(i, subFolder);
return;
}
}
collection.Add(subFolder);
}
private bool _isExpanded = true;
}
public class TagCollectionAsList
{
public AvaloniaList<Models.Tag> Tags
{
get;
set;
} = [];
}
public class TagCollectionAsTree
{
public List<TagTreeNode> Tree
{
get;
set;
} = [];
public AvaloniaList<TagTreeNode> Rows
{
get;
set;
} = [];
}
}

View file

@ -188,93 +188,35 @@
<!-- Tags --> <!-- Tags -->
<ToggleButton Grid.Row="4" Classes="group_expander" IsChecked="{Binding IsTagGroupExpanded, Mode=TwoWay}"> <ToggleButton Grid.Row="4" Classes="group_expander" IsChecked="{Binding IsTagGroupExpanded, Mode=TwoWay}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="group_header_label" Margin="0" Text="{DynamicResource Text.Repository.Tags}"/> <TextBlock Grid.Column="0" Classes="group_header_label" Margin="0" Text="{DynamicResource Text.Repository.Tags}"/>
<TextBlock Grid.Column="1" Text="{Binding Tags, Converter={x:Static c:ListConverters.ToCount}}" Foreground="{DynamicResource Brush.FG2}" FontWeight="Bold"/> <TextBlock Grid.Column="1" Text="{Binding Tags, Converter={x:Static c:ListConverters.ToCount}}" Foreground="{DynamicResource Brush.FG2}" FontWeight="Bold"/>
<Button Grid.Column="2" Classes="icon_button" Width="14" Margin="8,0" Command="{Binding CreateNewTag}" ToolTip.Tip="{DynamicResource Text.Repository.Tags.Add}"> <ToggleButton Grid.Column="2"
Classes="tag_display_mode"
Width="14"
IsChecked="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowTagsAsTree, Mode=TwoWay}"
ToolTip.Tip="{DynamicResource Text.Repository.ShowTagsAsTree}"/>
<Button Grid.Column="3"
Classes="icon_button"
Width="14"
Margin="8,0"
Command="{Binding CreateNewTag}"
ToolTip.Tip="{DynamicResource Text.Repository.Tags.Add}">
<Path Width="12" Height="12" Data="{StaticResource Icons.Tag.Add}"/> <Path Width="12" Height="12" Data="{StaticResource Icons.Tag.Add}"/>
</Button> </Button>
</Grid> </Grid>
</ToggleButton> </ToggleButton>
<DataGrid Grid.Row="5" <v:TagsView Grid.Row="5"
x:Name="TagsList" x:Name="TagsList"
Height="0" Height="0"
Margin="8,0,4,0" Margin="8,0,4,0"
Background="Transparent" Background="Transparent"
ItemsSource="{Binding VisibleTags}" ShowTagsAsTree="{Binding Source={x:Static vm:Preference.Instance}, Path=ShowTagsAsTree, Mode=OneWay}"
SelectionMode="Single" Tags="{Binding VisibleTags}"
CanUserReorderColumns="False" Focusable="False"
CanUserResizeColumns="False" IsVisible="{Binding IsTagGroupExpanded, Mode=OneWay}"
CanUserSortColumns="False" SelectionChanged="OnTagsSelectionChanged"
IsReadOnly="True" RowsChanged="OnTagsRowsChanged"/>
HeadersVisibility="None"
Focusable="False"
RowHeight="24"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"
IsVisible="{Binding IsTagGroupExpanded, Mode=OneWay}"
SelectionChanged="OnTagDataGridSelectionChanged"
ContextRequested="OnTagContextRequested"
PropertyChanged="OnLeftSidebarDataGridPropertyChanged">
<DataGrid.Styles>
<Style Selector="DataGridRow">
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Height" Value="24"/>
</Style>
<Style Selector="DataGridRow /template/ Border#RowBorder">
<Setter Property="ClipToBounds" Value="True" />
</Style>
<Style Selector="Grid.repository_leftpanel DataGridRow:pointerover /template/ Rectangle#BackgroundRectangle">
<Setter Property="Fill" Value="{DynamicResource Brush.AccentHovered}" />
<Setter Property="Opacity" Value=".5"/>
</Style>
<Style Selector="Grid.repository_leftpanel DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
<Setter Property="Fill" Value="{DynamicResource Brush.AccentHovered}" />
<Setter Property="Opacity" Value="1"/>
</Style>
<Style Selector="Grid.repository_leftpanel:focus-within DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
<Setter Property="Fill" Value="{DynamicResource Brush.Accent}" />
<Setter Property="Opacity" Value=".65"/>
</Style>
<Style Selector="Grid.repository_leftpanel:focus-within DataGridRow:selected:pointerover /template/ Rectangle#BackgroundRectangle">
<Setter Property="Fill" Value="{DynamicResource Brush.Accent}" />
<Setter Property="Opacity" Value=".8"/>
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTemplateColumn Header="ICON">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="{x:Type m:Tag}">
<Path Width="10" Height="10" Margin="8,0" Data="{StaticResource Icons.Tag}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="*" Header="NAME">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="{x:Type m:Tag}">
<TextBlock Text="{Binding Name}" Classes="primary" TextTrimming="CharacterEllipsis" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="FILTER">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="{x:Type m:Tag}">
<ToggleButton Classes="filter"
Margin="0,0,8,0"
Background="Transparent"
IsCheckedChanged="OnTagFilterIsCheckedChanged"
IsChecked="{Binding IsFiltered}"
ToolTip.Tip="{DynamicResource Text.Filter}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<!-- Submodules --> <!-- Submodules -->
<ToggleButton Grid.Row="6" Classes="group_expander" IsChecked="{Binding IsSubmoduleGroupExpanded, Mode=TwoWay}"> <ToggleButton Grid.Row="6" Classes="group_expander" IsChecked="{Binding IsSubmoduleGroupExpanded, Mode=TwoWay}">

View file

@ -2,7 +2,6 @@ using System;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@ -30,6 +29,9 @@ namespace SourceGit.Views
private void OnSearchKeyDown(object _, KeyEventArgs e) private void OnSearchKeyDown(object _, KeyEventArgs e)
{ {
var repo = DataContext as ViewModels.Repository; var repo = DataContext as ViewModels.Repository;
if (repo == null)
return;
if (e.Key == Key.Enter) if (e.Key == Key.Enter)
{ {
if (!string.IsNullOrWhiteSpace(repo.SearchCommitFilter)) if (!string.IsNullOrWhiteSpace(repo.SearchCommitFilter))
@ -79,46 +81,25 @@ namespace SourceGit.Views
private void OnLocalBranchTreeSelectionChanged(object _1, RoutedEventArgs _2) private void OnLocalBranchTreeSelectionChanged(object _1, RoutedEventArgs _2)
{ {
RemoteBranchTree.UnselectAll(); RemoteBranchTree.UnselectAll();
TagsList.SelectedItem = null; TagsList.UnselectAll();
} }
private void OnRemoteBranchTreeSelectionChanged(object _1, RoutedEventArgs _2) private void OnRemoteBranchTreeSelectionChanged(object _1, RoutedEventArgs _2)
{ {
LocalBranchTree.UnselectAll(); 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 }) UpdateLeftSidebarLayout();
{
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);
}
e.Handled = true; 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) LocalBranchTree.UnselectAll();
{ RemoteBranchTree.UnselectAll();
repo.UpdateFilter(tag.Name, toggle.IsChecked == true);
}
e.Handled = true;
} }
private void OnSubmoduleContextRequested(object sender, ContextRequestedEventArgs e) private void OnSubmoduleContextRequested(object sender, ContextRequestedEventArgs e)
@ -188,7 +169,7 @@ namespace SourceGit.Views
var localBranchRows = vm.IsLocalBranchGroupExpanded ? LocalBranchTree.Rows.Count : 0; var localBranchRows = vm.IsLocalBranchGroupExpanded ? LocalBranchTree.Rows.Count : 0;
var remoteBranchRows = vm.IsRemoteGroupExpanded ? RemoteBranchTree.Rows.Count : 0; var remoteBranchRows = vm.IsRemoteGroupExpanded ? RemoteBranchTree.Rows.Count : 0;
var desiredBranches = (localBranchRows + remoteBranchRows) * 24.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 desiredSubmodule = vm.IsSubmoduleGroupExpanded ? SubmoduleList.RowHeight * vm.Submodules.Count : 0;
var desiredWorktree = vm.IsWorktreeGroupExpanded ? WorktreeList.RowHeight * vm.Worktrees.Count : 0; var desiredWorktree = vm.IsWorktreeGroupExpanded ? WorktreeList.RowHeight * vm.Worktrees.Count : 0;
var desiredOthers = desiredTag + desiredSubmodule + desiredWorktree; 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; var repo = DataContext as ViewModels.Repository;
if (repo == null)
return;
if (e.Key == Key.Escape) if (e.Key == Key.Escape)
{ {
repo.IsSearchCommitSuggestionOpen = false; repo.IsSearchCommitSuggestionOpen = false;
@ -317,6 +301,9 @@ namespace SourceGit.Views
private void OnSearchSuggestionDoubleTapped(object sender, TappedEventArgs e) private void OnSearchSuggestionDoubleTapped(object sender, TappedEventArgs e)
{ {
var repo = DataContext as ViewModels.Repository; var repo = DataContext as ViewModels.Repository;
if (repo == null)
return;
var content = (sender as StackPanel)?.DataContext as string; var content = (sender as StackPanel)?.DataContext as string;
if (!string.IsNullOrEmpty(content)) if (!string.IsNullOrEmpty(content))
{ {

103
src/Views/TagsView.axaml Normal file
View file

@ -0,0 +1,103 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:m="using:SourceGit.Models"
xmlns:v="using:SourceGit.Views"
xmlns:vm="using:SourceGit.ViewModels"
xmlns:c="using:SourceGit.Converters"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="SourceGit.Views.TagsView">
<UserControl.Styles>
<Style Selector="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<VirtualizingStackPanel Orientation="Vertical"/>
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBoxItem">
<Setter Property="Height" Value="24"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
</UserControl.Styles>
<UserControl.DataTemplates>
<DataTemplate DataType="vm:TagCollectionAsTree">
<ListBox ItemsSource="{Binding Rows}"
SelectionMode="Single"
SelectionChanged="OnRowSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:TagTreeNode">
<Grid ColumnDefinitions="16,Auto,*,Auto"
Margin="{Binding Depth, Converter={x:Static c:IntConverters.ToTreeMargin}}"
Background="Transparent"
ContextRequested="OnRowContextRequested"
DoubleTapped="OnDoubleTappedNode">
<v:TagTreeNodeToggleButton Grid.Column="0"
Classes="tree_expander"
Focusable="False"
HorizontalAlignment="Center"
IsChecked="{Binding IsExpanded, Mode=OneWay}"
IsVisible="{Binding IsFolder}"/>
<v:TagTreeNodeIcon Grid.Column="1"
Node="{Binding .}"
IsExpanded="{Binding IsExpanded, Mode=OneWay}"/>
<TextBlock Grid.Column="2"
Classes="primary"
Text="{Binding FullPath, Converter={x:Static c:PathConverters.PureFileName}}"
Margin="8,0,0,0"/>
<ToggleButton Grid.Column="3"
Classes="filter"
Margin="0,0,8,0"
Background="Transparent"
IsCheckedChanged="OnToggleFilter"
IsChecked="{Binding IsFiltered}"
ToolTip.Tip="{DynamicResource Text.Filter}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DataTemplate>
<DataTemplate DataType="vm:TagCollectionAsList">
<ListBox ItemsSource="{Binding Tags}"
SelectionMode="Single"
SelectionChanged="OnRowSelectionChanged">
<ListBox.ItemTemplate>
<DataTemplate DataType="m:Tag">
<Grid ColumnDefinitions="Auto,*,Auto" Background="Transparent" ContextRequested="OnRowContextRequested">
<Path Grid.Column="0"
Width="10" Height="10"
Margin="8,0,0,0"
Data="{StaticResource Icons.Tag}"/>
<TextBlock Grid.Column="1"
Classes="primary"
Text="{Binding Name}"
Margin="8,0,0,0"/>
<ToggleButton Grid.Column="2"
Classes="filter"
Margin="0,0,8,0"
Background="Transparent"
IsCheckedChanged="OnToggleFilter"
IsChecked="{Binding IsFiltered}"
ToolTip.Tip="{DynamicResource Text.Filter}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DataTemplate>
</UserControl.DataTemplates>
</UserControl>

324
src/Views/TagsView.axaml.cs Normal file
View file

@ -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<TagsView>();
view?.ToggleNodeIsExpanded(node);
}
e.Handled = true;
}
}
public class TagTreeNodeIcon : UserControl
{
public static readonly StyledProperty<ViewModels.TagTreeNode> NodeProperty =
AvaloniaProperty.Register<TagTreeNodeIcon, ViewModels.TagTreeNode>(nameof(Node));
public ViewModels.TagTreeNode Node
{
get => GetValue(NodeProperty);
set => SetValue(NodeProperty, value);
}
public static readonly StyledProperty<bool> IsExpandedProperty =
AvaloniaProperty.Register<TagTreeNodeIcon, bool>(nameof(IsExpanded));
public bool IsExpanded
{
get => GetValue(IsExpandedProperty);
set => SetValue(IsExpandedProperty, value);
}
static TagTreeNodeIcon()
{
NodeProperty.Changed.AddClassHandler<TagTreeNodeIcon>((icon, _) => icon.UpdateContent());
IsExpandedProperty.Changed.AddClassHandler<TagTreeNodeIcon>((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<bool> ShowTagsAsTreeProperty =
AvaloniaProperty.Register<TagsView, bool>(nameof(ShowTagsAsTree));
public bool ShowTagsAsTree
{
get => GetValue(ShowTagsAsTreeProperty);
set => SetValue(ShowTagsAsTreeProperty, value);
}
public static readonly StyledProperty<List<Models.Tag>> TagsProperty =
AvaloniaProperty.Register<TagsView, List<Models.Tag>>(nameof(Tags));
public List<Models.Tag> Tags
{
get => GetValue(TagsProperty);
set => SetValue(TagsProperty, value);
}
public static readonly RoutedEvent<RoutedEventArgs> SelectionChangedEvent =
RoutedEvent.Register<TagsView, RoutedEventArgs>(nameof(SelectionChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
public event EventHandler<RoutedEventArgs> SelectionChanged
{
add { AddHandler(SelectionChangedEvent, value); }
remove { RemoveHandler(SelectionChangedEvent, value); }
}
public static readonly RoutedEvent<RoutedEventArgs> RowsChangedEvent =
RoutedEvent.Register<TagsView, RoutedEventArgs>(nameof(RowsChanged), RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
public event EventHandler<RoutedEventArgs> 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<ListBox>();
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<ViewModels.TagTreeNode>();
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<ViewModels.TagTreeNode> rows, List<ViewModels.TagTreeNode> 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<string>();
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<ViewModels.TagTreeNode>();
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));
}
}
}