Files
sourcegit/src/Views/RevisionFileTreeView.axaml.cs

756 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
namespace SourceGit.Views
{
public class RevisionFileTreeNodeToggleButton : ToggleButton
{
protected override Type StyleKeyOverride => typeof(ToggleButton);
protected override async void OnPointerPressed(PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed &&
DataContext is ViewModels.RevisionFileTreeNode { IsFolder: true } node)
{
var tree = this.FindAncestorOfType<RevisionFileTreeView>();
if (tree != null)
await tree.ToggleNodeIsExpandedAsync(node);
}
e.Handled = true;
}
}
public class RevisionTreeNodeIcon : UserControl
{
public static readonly StyledProperty<ViewModels.RevisionFileTreeNode> NodeProperty =
AvaloniaProperty.Register<RevisionTreeNodeIcon, ViewModels.RevisionFileTreeNode>(nameof(Node));
public ViewModels.RevisionFileTreeNode Node
{
get => GetValue(NodeProperty);
set => SetValue(NodeProperty, value);
}
public static readonly StyledProperty<bool> IsExpandedProperty =
AvaloniaProperty.Register<RevisionTreeNodeIcon, bool>(nameof(IsExpanded));
public bool IsExpanded
{
get => GetValue(IsExpandedProperty);
set => SetValue(IsExpandedProperty, value);
}
static RevisionTreeNodeIcon()
{
NodeProperty.Changed.AddClassHandler<RevisionTreeNodeIcon>((icon, _) => icon.UpdateContent());
IsExpandedProperty.Changed.AddClassHandler<RevisionTreeNodeIcon>((icon, _) => icon.UpdateContent());
}
private void UpdateContent()
{
var node = Node;
if (node?.Backend == null)
{
Content = null;
return;
}
var obj = node.Backend;
switch (obj.Type)
{
case Models.ObjectType.Blob:
CreateContent("Icons.File", new Thickness(0, 0, 0, 0));
break;
case Models.ObjectType.Commit:
CreateContent("Icons.Submodule", new Thickness(0, 0, 0, 0));
break;
default:
CreateContent(node.IsExpanded ? "Icons.Folder.Open" : "Icons.Folder", new Thickness(0, 2, 0, 0), Brushes.Goldenrod);
break;
}
}
private void CreateContent(string iconKey, Thickness margin, IBrush fill = null)
{
if (this.FindResource(iconKey) is not StreamGeometry geo)
return;
var icon = new Avalonia.Controls.Shapes.Path()
{
Width = 14,
Height = 14,
Margin = margin,
Stretch = Stretch.Uniform,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
Data = geo,
};
if (fill != null)
icon.Fill = fill;
Content = icon;
}
}
public class RevisionFileRowsListBox : ListBox
{
protected override Type StyleKeyOverride => typeof(ListBox);
protected override async void OnKeyDown(KeyEventArgs e)
{
if (SelectedItem is ViewModels.RevisionFileTreeNode node)
{
if (node.IsFolder &&
e.KeyModifiers == KeyModifiers.None &&
(node.IsExpanded && e.Key == Key.Left) || (!node.IsExpanded && e.Key == Key.Right))
{
var tree = this.FindAncestorOfType<RevisionFileTreeView>();
if (tree != null)
await tree.ToggleNodeIsExpandedAsync(node);
e.Handled = true;
}
else if (e.Key == Key.C &&
e.KeyModifiers.HasFlag(OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control))
{
var detailView = this.FindAncestorOfType<CommitDetail>();
if (detailView is { DataContext: ViewModels.CommitDetail detail })
{
var path = node.Backend?.Path ?? string.Empty;
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
path = detail.GetAbsPath(path);
await App.CopyTextAsync(path);
e.Handled = true;
}
}
else if (node.Backend is { Type: Models.ObjectType.Blob } file &&
e.Key == Key.S &&
e.KeyModifiers == ((OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control) | KeyModifiers.Shift))
{
var detailView = this.FindAncestorOfType<CommitDetail>();
if (detailView is { DataContext: ViewModels.CommitDetail detail })
{
var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider;
if (storageProvider == null)
return;
var options = new FolderPickerOpenOptions() { AllowMultiple = false };
try
{
var selected = await storageProvider.OpenFolderPickerAsync(options);
if (selected.Count == 1)
{
var folder = selected[0];
var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString();
var saveTo = Path.Combine(folderPath, Path.GetFileName(file.Path)!);
await detail.SaveRevisionFileAsync(file, saveTo);
}
}
catch (Exception ex)
{
App.RaiseException(detail.Repository.FullPath, $"Failed to save file: {ex.Message}");
}
e.Handled = true;
}
}
}
if (!e.Handled)
base.OnKeyDown(e);
}
}
public partial class RevisionFileTreeView : UserControl
{
public static readonly StyledProperty<string> RevisionProperty =
AvaloniaProperty.Register<RevisionFileTreeView, string>(nameof(Revision));
public string Revision
{
get => GetValue(RevisionProperty);
set => SetValue(RevisionProperty, value);
}
public AvaloniaList<ViewModels.RevisionFileTreeNode> Rows { get; } = [];
public RevisionFileTreeView()
{
InitializeComponent();
}
public async Task SetSearchResultAsync(string file)
{
Rows.Clear();
_searchResult.Clear();
var rows = new List<ViewModels.RevisionFileTreeNode>();
if (string.IsNullOrEmpty(file))
{
MakeRows(rows, _tree, 0);
}
else
{
var vm = DataContext as ViewModels.CommitDetail;
if (vm?.Commit == null)
return;
var objects = await vm.GetRevisionFilesUnderFolderAsync(file);
if (objects is not { Count: 1 })
return;
var routes = file.Split('/');
if (routes.Length == 1)
{
_searchResult.Add(new ViewModels.RevisionFileTreeNode
{
Backend = objects[0]
});
}
else
{
var last = _searchResult;
var prefix = string.Empty;
for (var i = 0; i < routes.Length - 1; i++)
{
var folder = new ViewModels.RevisionFileTreeNode
{
Backend = new Models.Object
{
Type = Models.ObjectType.Tree,
Path = prefix + routes[i],
},
IsExpanded = true,
};
last.Add(folder);
last = folder.Children;
prefix = folder.Backend + "/";
}
last.Add(new ViewModels.RevisionFileTreeNode
{
Backend = objects[0]
});
}
MakeRows(rows, _searchResult, 0);
}
Rows.AddRange(rows);
GC.Collect();
}
public async Task ToggleNodeIsExpandedAsync(ViewModels.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 = await GetChildrenOfTreeNodeAsync(node);
if (subtree is { Count: > 0 })
{
var subrows = new List<ViewModels.RevisionFileTreeNode>();
MakeRows(subrows, subtree, 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 async void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == RevisionProperty)
{
_tree.Clear();
_searchResult.Clear();
if (DataContext is ViewModels.CommitDetail { ActiveTabIndex: 2 } vm)
await ReloadTreeData(vm);
else
Rows.Clear();
}
}
protected override async void OnLoaded(RoutedEventArgs e)
{
base.OnLoaded(e);
if (DataContext is ViewModels.CommitDetail vm && _tree.Count == 0)
await ReloadTreeData(vm);
}
private void OnTreeNodeContextRequested(object sender, ContextRequestedEventArgs e)
{
if (DataContext is ViewModels.CommitDetail { Repository: { } repo, Commit: { } commit } vm &&
sender is Grid { DataContext: ViewModels.RevisionFileTreeNode { Backend: { } obj } } grid)
{
var menu = obj.Type switch
{
Models.ObjectType.Tree => CreateRevisionFileContextMenuByFolder(repo, vm, commit, obj.Path),
_ => CreateRevisionFileContextMenu(repo, vm, commit, obj),
};
menu.Open(grid);
}
e.Handled = true;
}
private async void OnTreeNodeDoubleTapped(object sender, TappedEventArgs e)
{
if (sender is Grid { DataContext: ViewModels.RevisionFileTreeNode { IsFolder: true } node })
{
var posX = e.GetPosition(this).X;
if (posX < node.Depth * 16 + 16)
return;
await ToggleNodeIsExpandedAsync(node);
}
}
private async void OnRowsSelectionChanged(object sender, SelectionChangedEventArgs _)
{
if (_disableSelectionChangingEvent || DataContext is not ViewModels.CommitDetail vm)
return;
if (sender is ListBox { SelectedItem: ViewModels.RevisionFileTreeNode { IsFolder: false } node })
await vm.ViewRevisionFileAsync(node.Backend);
else
await vm.ViewRevisionFileAsync(null);
}
private async Task<List<ViewModels.RevisionFileTreeNode>> GetChildrenOfTreeNodeAsync(ViewModels.RevisionFileTreeNode node)
{
if (!node.IsFolder)
return null;
if (node.Children.Count > 0)
return node.Children;
if (DataContext is not ViewModels.CommitDetail vm)
return null;
var objects = await vm.GetRevisionFilesUnderFolderAsync(node.Backend.Path + "/");
if (objects == null || objects.Count == 0)
return null;
foreach (var obj in objects)
node.Children.Add(new ViewModels.RevisionFileTreeNode() { Backend = obj });
SortNodes(node.Children);
return node.Children;
}
private void MakeRows(List<ViewModels.RevisionFileTreeNode> rows, List<ViewModels.RevisionFileTreeNode> 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 void SortNodes(List<ViewModels.RevisionFileTreeNode> nodes)
{
nodes.Sort((l, r) =>
{
if (l.IsFolder == r.IsFolder)
return Models.NumericSort.Compare(l.Name, r.Name);
return l.IsFolder ? -1 : 1;
});
}
private async Task ReloadTreeData(ViewModels.CommitDetail vm)
{
if (_isReloadingTreeData)
return;
_isReloadingTreeData = true;
if (vm?.Commit == null)
{
Rows.Clear();
_isReloadingTreeData = false;
return;
}
var objects = await vm.GetRevisionFilesUnderFolderAsync(null);
if (objects == null || objects.Count == 0)
{
Rows.Clear();
_isReloadingTreeData = false;
return;
}
foreach (var obj in objects)
_tree.Add(new ViewModels.RevisionFileTreeNode { Backend = obj });
SortNodes(_tree);
var topTree = new List<ViewModels.RevisionFileTreeNode>();
MakeRows(topTree, _tree, 0);
Rows.Clear();
Rows.AddRange(topTree);
_isReloadingTreeData = false;
}
private ContextMenu CreateRevisionFileContextMenuByFolder(ViewModels.Repository repo, ViewModels.CommitDetail vm, Models.Commit commit, string path)
{
var fullPath = Native.OS.GetAbsPath(repo.FullPath, path);
var explore = new MenuItem();
explore.Header = App.Text("RevealFile");
explore.Icon = App.CreateMenuIcon("Icons.Explore");
explore.IsEnabled = Directory.Exists(fullPath);
explore.Click += (_, ev) =>
{
Native.OS.OpenInFileManager(fullPath, true);
ev.Handled = true;
};
var history = new MenuItem();
history.Header = App.Text("DirHistories");
history.Icon = App.CreateMenuIcon("Icons.Histories");
history.Click += (_, ev) =>
{
App.ShowWindow(new ViewModels.DirHistories(repo, path, commit.SHA));
ev.Handled = true;
};
var copyPath = new MenuItem();
copyPath.Header = App.Text("CopyPath");
copyPath.Icon = App.CreateMenuIcon("Icons.Copy");
copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C";
copyPath.Click += async (_, ev) =>
{
await App.CopyTextAsync(path);
ev.Handled = true;
};
var copyFullPath = new MenuItem();
copyFullPath.Header = App.Text("CopyFullPath");
copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy");
copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C";
copyFullPath.Click += async (_, e) =>
{
await App.CopyTextAsync(fullPath);
e.Handled = true;
};
var menu = new ContextMenu();
menu.Items.Add(explore);
menu.Items.Add(new MenuItem() { Header = "-" });
menu.Items.Add(history);
menu.Items.Add(new MenuItem() { Header = "-" });
menu.Items.Add(copyPath);
menu.Items.Add(copyFullPath);
return menu;
}
private ContextMenu CreateRevisionFileContextMenu(ViewModels.Repository repo, ViewModels.CommitDetail vm, Models.Commit commit, Models.Object file)
{
var fullPath = Native.OS.GetAbsPath(repo.FullPath, file.Path);
var menu = new ContextMenu();
var openWith = new MenuItem();
openWith.Header = App.Text("Open");
openWith.Icon = App.CreateMenuIcon("Icons.OpenWith");
openWith.IsEnabled = file.Type == Models.ObjectType.Blob;
if (openWith.IsEnabled)
{
var defaultEditor = new MenuItem();
defaultEditor.Header = App.Text("Open.SystemDefaultEditor");
defaultEditor.Tag = OperatingSystem.IsMacOS() ? "⌘+O" : "Ctrl+O";
defaultEditor.Click += async (_, ev) =>
{
await vm.OpenRevisionFileAsync(file.Path, null);
ev.Handled = true;
};
openWith.Items.Add(defaultEditor);
var tools = Native.OS.ExternalTools;
if (tools.Count > 0)
{
openWith.Items.Add(new MenuItem() { Header = "-" });
for (var i = 0; i < tools.Count; i++)
{
var tool = tools[i];
var item = new MenuItem();
item.Header = tool.Name;
item.Icon = new Image { Width = 16, Height = 16, Source = tool.IconImage };
item.Click += async (_, ev) =>
{
await vm.OpenRevisionFileAsync(file.Path, tool);
ev.Handled = true;
};
openWith.Items.Add(item);
}
}
}
var saveAs = new MenuItem();
saveAs.Header = App.Text("SaveAs");
saveAs.Icon = App.CreateMenuIcon("Icons.Save");
saveAs.IsEnabled = file.Type == Models.ObjectType.Blob;
saveAs.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+S" : "Ctrl+Shift+S";
saveAs.Click += async (_, ev) =>
{
var storageProvider = TopLevel.GetTopLevel(this)?.StorageProvider;
if (storageProvider == null)
return;
var options = new FolderPickerOpenOptions() { AllowMultiple = false };
try
{
var selected = await storageProvider.OpenFolderPickerAsync(options);
if (selected.Count == 1)
{
var folder = selected[0];
var folderPath = folder is { Path: { IsAbsoluteUri: true } path } ? path.LocalPath : folder.Path.ToString();
var saveTo = Path.Combine(folderPath, Path.GetFileName(file.Path)!);
await vm.SaveRevisionFileAsync(file, saveTo);
}
}
catch (Exception e)
{
App.RaiseException(repo.FullPath, $"Failed to save file: {e.Message}");
}
ev.Handled = true;
};
var explore = new MenuItem();
explore.Header = App.Text("RevealFile");
explore.Icon = App.CreateMenuIcon("Icons.Explore");
explore.IsEnabled = File.Exists(fullPath);
explore.Click += (_, ev) =>
{
Native.OS.OpenInFileManager(fullPath, file.Type == Models.ObjectType.Blob);
ev.Handled = true;
};
menu.Items.Add(openWith);
menu.Items.Add(saveAs);
menu.Items.Add(explore);
menu.Items.Add(new MenuItem() { Header = "-" });
var history = new MenuItem();
history.Header = App.Text("FileHistory");
history.Icon = App.CreateMenuIcon("Icons.Histories");
history.Click += (_, ev) =>
{
App.ShowWindow(new ViewModels.FileHistories(repo.FullPath, file.Path, commit.SHA));
ev.Handled = true;
};
var blame = new MenuItem();
blame.Header = App.Text("Blame");
blame.Icon = App.CreateMenuIcon("Icons.Blame");
blame.IsEnabled = file.Type == Models.ObjectType.Blob;
blame.Click += (_, ev) =>
{
App.ShowWindow(new ViewModels.Blame(repo.FullPath, file.Path, commit));
ev.Handled = true;
};
menu.Items.Add(history);
menu.Items.Add(blame);
menu.Items.Add(new MenuItem() { Header = "-" });
if (!repo.IsBare)
{
var resetToThisRevision = new MenuItem();
resetToThisRevision.Header = App.Text("ChangeCM.CheckoutThisRevision");
resetToThisRevision.Icon = App.CreateMenuIcon("Icons.File.Checkout");
resetToThisRevision.Click += async (_, ev) =>
{
await vm.ResetToThisRevisionAsync(file.Path);
ev.Handled = true;
};
var change = vm.Changes.Find(x => x.Path == file.Path) ?? new Models.Change() { Index = Models.ChangeState.None, Path = file.Path };
var resetToFirstParent = new MenuItem();
resetToFirstParent.Header = App.Text("ChangeCM.CheckoutFirstParentRevision");
resetToFirstParent.Icon = App.CreateMenuIcon("Icons.File.Checkout");
resetToFirstParent.IsEnabled = commit.Parents.Count > 0;
resetToFirstParent.Click += async (_, ev) =>
{
await vm.ResetToParentRevisionAsync(change);
ev.Handled = true;
};
menu.Items.Add(resetToThisRevision);
menu.Items.Add(resetToFirstParent);
menu.Items.Add(new MenuItem() { Header = "-" });
if (repo.Remotes.Count > 0 && File.Exists(fullPath) && repo.IsLFSEnabled())
{
var lfs = new MenuItem();
lfs.Header = App.Text("GitLFS");
lfs.Icon = App.CreateMenuIcon("Icons.LFS");
var lfsLock = new MenuItem();
lfsLock.Header = App.Text("GitLFS.Locks.Lock");
lfsLock.Icon = App.CreateMenuIcon("Icons.Lock");
if (repo.Remotes.Count == 1)
{
lfsLock.Click += async (_, e) =>
{
await repo.LockLFSFileAsync(repo.Remotes[0].Name, change.Path);
e.Handled = true;
};
}
else
{
foreach (var remote in repo.Remotes)
{
var remoteName = remote.Name;
var lockRemote = new MenuItem();
lockRemote.Header = remoteName;
lockRemote.Click += async (_, e) =>
{
await repo.LockLFSFileAsync(remoteName, change.Path);
e.Handled = true;
};
lfsLock.Items.Add(lockRemote);
}
}
lfs.Items.Add(lfsLock);
var lfsUnlock = new MenuItem();
lfsUnlock.Header = App.Text("GitLFS.Locks.Unlock");
lfsUnlock.Icon = App.CreateMenuIcon("Icons.Unlock");
if (repo.Remotes.Count == 1)
{
lfsUnlock.Click += async (_, e) =>
{
await repo.UnlockLFSFileAsync(repo.Remotes[0].Name, change.Path, false, true);
e.Handled = true;
};
}
else
{
foreach (var remote in repo.Remotes)
{
var remoteName = remote.Name;
var unlockRemote = new MenuItem();
unlockRemote.Header = remoteName;
unlockRemote.Click += async (_, e) =>
{
await repo.UnlockLFSFileAsync(remoteName, change.Path, false, true);
e.Handled = true;
};
lfsUnlock.Items.Add(unlockRemote);
}
}
lfs.Items.Add(lfsUnlock);
menu.Items.Add(lfs);
menu.Items.Add(new MenuItem() { Header = "-" });
}
}
var actions = repo.GetCustomActions(Models.CustomActionScope.File);
if (actions.Count > 0)
{
var target = new Models.CustomActionTargetFile(file.Path, vm.Commit);
var custom = new MenuItem();
custom.Header = App.Text("FileCM.CustomAction");
custom.Icon = App.CreateMenuIcon("Icons.Action");
foreach (var action in actions)
{
var (dup, label) = action;
var item = new MenuItem();
item.Icon = App.CreateMenuIcon("Icons.Action");
item.Header = label;
item.Click += async (_, e) =>
{
await repo.ExecCustomActionAsync(dup, target);
e.Handled = true;
};
custom.Items.Add(item);
}
menu.Items.Add(custom);
menu.Items.Add(new MenuItem() { Header = "-" });
}
var copyPath = new MenuItem();
copyPath.Header = App.Text("CopyPath");
copyPath.Icon = App.CreateMenuIcon("Icons.Copy");
copyPath.Tag = OperatingSystem.IsMacOS() ? "⌘+C" : "Ctrl+C";
copyPath.Click += async (_, ev) =>
{
await App.CopyTextAsync(file.Path);
ev.Handled = true;
};
var copyFullPath = new MenuItem();
copyFullPath.Header = App.Text("CopyFullPath");
copyFullPath.Icon = App.CreateMenuIcon("Icons.Copy");
copyFullPath.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+C" : "Ctrl+Shift+C";
copyFullPath.Click += async (_, e) =>
{
await App.CopyTextAsync(fullPath);
e.Handled = true;
};
menu.Items.Add(copyPath);
menu.Items.Add(copyFullPath);
return menu;
}
private List<ViewModels.RevisionFileTreeNode> _tree = [];
private bool _disableSelectionChangingEvent = false;
private List<ViewModels.RevisionFileTreeNode> _searchResult = [];
private bool _isReloadingTreeData = false;
}
}