From fa44d9c378e0a7c5f1012cba94dcb45936fbf071 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 23 Jul 2025 15:56:13 +0800 Subject: [PATCH] refactor: move context menu creation from `ViewModels` to `Views` (PART 8) Signed-off-by: leo --- src/ViewModels/CommitDetail.cs | 564 +++--------------------- src/Views/CommitChanges.axaml.cs | 8 +- src/Views/CommitDetail.axaml.cs | 288 +++++++++++- src/Views/RevisionFileTreeView.axaml.cs | 289 +++++++++++- 4 files changed, 624 insertions(+), 525 deletions(-) diff --git a/src/ViewModels/CommitDetail.cs b/src/ViewModels/CommitDetail.cs index a1fc6d37..fbf0d4aa 100644 --- a/src/ViewModels/CommitDetail.cs +++ b/src/ViewModels/CommitDetail.cs @@ -5,16 +5,18 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using Avalonia.Controls; -using Avalonia.Platform.Storage; using Avalonia.Threading; - using CommunityToolkit.Mvvm.ComponentModel; namespace SourceGit.ViewModels { public partial class CommitDetail : ObservableObject, IDisposable { + public Repository Repository + { + get => _repo; + } + public int ActivePageIndex { get => _rememberActivePageIndex ? _repo.CommitDetailActivePageIndex : _activePageIndex; @@ -198,6 +200,48 @@ namespace SourceGit.ViewModels .ConfigureAwait(false); } + public string GetAbsPath(string path) + { + return Native.OS.GetAbsPath(_repo.FullPath, path); + } + + public void OpenChangeInMergeTool(Models.Change c) + { + var toolType = Preferences.Instance.ExternalMergeToolType; + var toolPath = Preferences.Instance.ExternalMergeToolPath; + var opt = new Models.DiffOption(_commit, c); + new Commands.DiffTool(_repo.FullPath, toolType, toolPath, opt).Open(); + } + + public async Task SaveChangesAsPatchAsync(List changes, string saveTo) + { + if (_commit == null) + return; + + var baseRevision = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; + var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, changes, baseRevision, _commit.SHA, saveTo); + if (succ) + App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); + } + + public async Task ResetToThisRevisionAsync(string path) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); + await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(path, $"{_commit.SHA}"); + log.Complete(); + } + + public async Task ResetToParentRevisionAsync(Models.Change change) + { + var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); + + if (change.Index == Models.ChangeState.Renamed) + await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(change.OriginalPath, $"{_commit.SHA}~1"); + + await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(change.Path, $"{_commit.SHA}~1"); + log.Complete(); + } + public async Task> GetRevisionFilesUnderFolderAsync(string parentFolder) { return await new Commands.QueryRevisionObjects(_repo.FullPath, _commit.SHA, parentFolder) @@ -241,429 +285,11 @@ namespace SourceGit.ViewModels Native.OS.OpenWithDefaultEditor(tmpFile); } - public string GetAbsPath(string path) + public async Task SaveRevisionFileAsync(Models.Object file, string saveTo) { - return Native.OS.GetAbsPath(_repo.FullPath, path); - } - - public void OpenChangeInMergeTool(Models.Change c) - { - var toolType = Preferences.Instance.ExternalMergeToolType; - var toolPath = Preferences.Instance.ExternalMergeToolPath; - var opt = new Models.DiffOption(_commit, c); - new Commands.DiffTool(_repo.FullPath, toolType, toolPath, opt).Open(); - } - - public async Task SaveRevisionFile(Models.Object file) - { - var storageProvider = App.GetStorageProvider(); - 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 Commands.SaveRevisionFile - .RunAsync(_repo.FullPath, _commit.SHA, file.Path, saveTo) - .ConfigureAwait(false); - } - } - catch (Exception e) - { - App.RaiseException(_repo.FullPath, $"Failed to save file: {e.Message}"); - } - } - - public ContextMenu CreateChangeContextMenuByFolder(ChangeTreeNode node, List changes) - { - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, node.FullPath); - 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 DirHistories(_repo, node.FullPath, _commit.SHA)); - ev.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var baseRevision = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var saveTo = storageFile.Path.LocalPath; - var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, changes, baseRevision, _commit.SHA, saveTo); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.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(node.FullPath); - 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(patch); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(copyPath); - menu.Items.Add(copyFullPath); - - return menu; - } - - public ContextMenu CreateChangeContextMenu(Models.Change change) - { - var openWithMerger = new MenuItem(); - openWithMerger.Header = App.Text("OpenInExternalMergeTool"); - openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; - openWithMerger.Click += (_, ev) => - { - OpenChangeInMergeTool(change); - ev.Handled = true; - }; - - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, change.Path); - 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, true); - ev.Handled = true; - }; - - var history = new MenuItem(); - history.Header = App.Text("FileHistory"); - history.Icon = App.CreateMenuIcon("Icons.Histories"); - history.Click += (_, ev) => - { - App.ShowWindow(new FileHistories(_repo, change.Path, _commit.SHA)); - ev.Handled = true; - }; - - var blame = new MenuItem(); - blame.Header = App.Text("Blame"); - blame.Icon = App.CreateMenuIcon("Icons.Blame"); - blame.IsEnabled = change.Index != Models.ChangeState.Deleted; - blame.Click += (_, ev) => - { - App.ShowWindow(new Blame(_repo.FullPath, change.Path, _commit)); - ev.Handled = true; - }; - - var patch = new MenuItem(); - patch.Header = App.Text("FileCM.SaveAsPatch"); - patch.Icon = App.CreateMenuIcon("Icons.Diff"); - patch.Click += async (_, e) => - { - var storageProvider = App.GetStorageProvider(); - if (storageProvider == null) - return; - - var options = new FilePickerSaveOptions(); - options.Title = App.Text("FileCM.SaveAsPatch"); - options.DefaultExtension = ".patch"; - options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; - - var baseRevision = _commit.Parents.Count == 0 ? Models.Commit.EmptyTreeSHA1 : _commit.Parents[0]; - var storageFile = await storageProvider.SaveFilePickerAsync(options); - if (storageFile != null) - { - var saveTo = storageFile.Path.LocalPath; - var succ = await Commands.SaveChangesAsPatch.ProcessRevisionCompareChangesAsync(_repo.FullPath, [change], baseRevision, _commit.SHA, saveTo); - if (succ) - App.SendNotification(_repo.FullPath, App.Text("SaveAsPatchSuccess")); - } - - e.Handled = true; - }; - - var menu = new ContextMenu(); - menu.Items.Add(openWithMerger); - menu.Items.Add(explore); - menu.Items.Add(new MenuItem { Header = "-" }); - menu.Items.Add(history); - menu.Items.Add(blame); - menu.Items.Add(patch); - 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 ResetToThisRevisionAsync(change.Path); - ev.Handled = true; - }; - - 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 ResetToParentRevisionAsync(change); - ev.Handled = true; - }; - - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem { Header = "-" }); - - TryToAddContextMenuItemsForGitLFS(menu, fullPath, change.Path); - } - - 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(change.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; - } - - public ContextMenu CreateRevisionFileContextMenuByFolder(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 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; - } - - public ContextMenu CreateRevisionFileContextMenu(Models.Object file) - { - if (file.Type == Models.ObjectType.Tree) - return CreateRevisionFileContextMenuByFolder(file.Path); - - var menu = new ContextMenu(); - var fullPath = Native.OS.GetAbsPath(_repo.FullPath, file.Path); - var openWith = new MenuItem(); - openWith.Header = App.Text("OpenWith"); - openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); - openWith.Tag = OperatingSystem.IsMacOS() ? "⌘+O" : "Ctrl+O"; - openWith.IsEnabled = file.Type == Models.ObjectType.Blob; - openWith.Click += async (_, ev) => - { - await OpenRevisionFileWithDefaultEditorAsync(file.Path); - ev.Handled = true; - }; - - 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) => - { - await SaveRevisionFile(file); - 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 FileHistories(_repo, 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 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 ResetToThisRevisionAsync(file.Path); - ev.Handled = true; - }; - - var change = _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 ResetToParentRevisionAsync(change); - ev.Handled = true; - }; - - menu.Items.Add(resetToThisRevision); - menu.Items.Add(resetToFirstParent); - menu.Items.Add(new MenuItem() { Header = "-" }); - - TryToAddContextMenuItemsForGitLFS(menu, fullPath, file.Path); - } - - 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; + await Commands.SaveRevisionFile + .RunAsync(_repo.FullPath, _commit.SHA, file.Path, saveTo) + .ConfigureAwait(false); } private void Refresh() @@ -823,75 +449,6 @@ namespace SourceGit.ViewModels } } - private void TryToAddContextMenuItemsForGitLFS(ContextMenu menu, string fullPath, string path) - { - if (_repo.Remotes.Count == 0 || !File.Exists(fullPath) || !_repo.IsLFSEnabled()) - return; - - 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, 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, 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, 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, path, false, true); - e.Handled = true; - }; - lfsUnlock.Items.Add(unlockRemote); - } - } - lfs.Items.Add(lfsUnlock); - - menu.Items.Add(lfs); - menu.Items.Add(new MenuItem() { Header = "-" }); - } - private void RefreshRevisionSearchSuggestion() { if (!string.IsNullOrEmpty(_revisionFileSearchFilter)) @@ -1011,25 +568,6 @@ namespace SourceGit.ViewModels } } - private async Task ResetToThisRevisionAsync(string path) - { - var log = _repo.CreateLog($"Reset File to '{_commit.SHA}'"); - - await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(path, $"{_commit.SHA}"); - log.Complete(); - } - - private async Task ResetToParentRevisionAsync(Models.Change change) - { - var log = _repo.CreateLog($"Reset File to '{_commit.SHA}~1'"); - - if (change.Index == Models.ChangeState.Renamed) - await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(change.OriginalPath, $"{_commit.SHA}~1"); - - await new Commands.Checkout(_repo.FullPath).Use(log).FileWithRevisionAsync(change.Path, $"{_commit.SHA}~1"); - log.Complete(); - } - [GeneratedRegex(@"\b(https?://|ftp://)[\w\d\._/\-~%@()+:?&=#!]*[\w\d/]")] private static partial Regex REG_URL_FORMAT(); diff --git a/src/Views/CommitChanges.axaml.cs b/src/Views/CommitChanges.axaml.cs index 8bd868fb..1691a1ae 100644 --- a/src/Views/CommitChanges.axaml.cs +++ b/src/Views/CommitChanges.axaml.cs @@ -17,18 +17,22 @@ namespace SourceGit.Views if (sender is not ChangeCollectionView view || DataContext is not ViewModels.CommitDetail vm) return; + var detailView = this.FindAncestorOfType(); + if (detailView == null) + return; + var changes = view.SelectedChanges ?? []; var container = view.FindDescendantOfType(); if (container is { SelectedItems.Count: 1, SelectedItem: ViewModels.ChangeTreeNode { IsFolder: true } node }) { - var menu = vm.CreateChangeContextMenuByFolder(node, changes); + var menu = detailView.CreateChangeContextMenuByFolder(node, changes); menu.Open(view); return; } if (changes.Count == 1) { - var menu = vm.CreateChangeContextMenu(changes[0]); + var menu = detailView.CreateChangeContextMenu(changes[0]); menu.Open(view); } } diff --git a/src/Views/CommitDetail.axaml.cs b/src/Views/CommitDetail.axaml.cs index ef4aa72d..cbacf272 100644 --- a/src/Views/CommitDetail.axaml.cs +++ b/src/Views/CommitDetail.axaml.cs @@ -1,6 +1,10 @@ -using System; +using System; +using System.Collections.Generic; +using System.IO; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Platform.Storage; +using SourceGit.ViewModels; namespace SourceGit.Views { @@ -11,6 +15,282 @@ namespace SourceGit.Views InitializeComponent(); } + public ContextMenu CreateChangeContextMenuByFolder(ChangeTreeNode node, List changes) + { + if (DataContext is not ViewModels.CommitDetail { Repository: ViewModels.Repository repo, Commit: Models.Commit commit } vm) + return null; + + var fullPath = Native.OS.GetAbsPath(repo.FullPath, node.FullPath); + 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, node.FullPath, commit.SHA)); + ev.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this).StorageProvider; + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + await vm.SaveChangesAsPatchAsync(changes, saveTo); + } + + e.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(node.FullPath); + 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(patch); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(copyPath); + menu.Items.Add(copyFullPath); + + return menu; + } + + public ContextMenu CreateChangeContextMenu(Models.Change change) + { + if (DataContext is not ViewModels.CommitDetail { Repository: ViewModels.Repository repo, Commit: Models.Commit commit } vm) + return null; + + var openWithMerger = new MenuItem(); + openWithMerger.Header = App.Text("OpenInExternalMergeTool"); + openWithMerger.Icon = App.CreateMenuIcon("Icons.OpenWith"); + openWithMerger.Tag = OperatingSystem.IsMacOS() ? "⌘+⇧+D" : "Ctrl+Shift+D"; + openWithMerger.Click += (_, ev) => + { + vm.OpenChangeInMergeTool(change); + ev.Handled = true; + }; + + var fullPath = Native.OS.GetAbsPath(repo.FullPath, change.Path); + 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, true); + ev.Handled = true; + }; + + var history = new MenuItem(); + history.Header = App.Text("FileHistory"); + history.Icon = App.CreateMenuIcon("Icons.Histories"); + history.Click += (_, ev) => + { + App.ShowWindow(new ViewModels.FileHistories(repo, change.Path, commit.SHA)); + ev.Handled = true; + }; + + var blame = new MenuItem(); + blame.Header = App.Text("Blame"); + blame.Icon = App.CreateMenuIcon("Icons.Blame"); + blame.IsEnabled = change.Index != Models.ChangeState.Deleted; + blame.Click += (_, ev) => + { + App.ShowWindow(new ViewModels.Blame(repo.FullPath, change.Path, commit)); + ev.Handled = true; + }; + + var patch = new MenuItem(); + patch.Header = App.Text("FileCM.SaveAsPatch"); + patch.Icon = App.CreateMenuIcon("Icons.Diff"); + patch.Click += async (_, e) => + { + var storageProvider = TopLevel.GetTopLevel(this).StorageProvider; + if (storageProvider == null) + return; + + var options = new FilePickerSaveOptions(); + options.Title = App.Text("FileCM.SaveAsPatch"); + options.DefaultExtension = ".patch"; + options.FileTypeChoices = [new FilePickerFileType("Patch File") { Patterns = ["*.patch"] }]; + + var storageFile = await storageProvider.SaveFilePickerAsync(options); + if (storageFile != null) + { + var saveTo = storageFile.Path.LocalPath; + await vm.SaveChangesAsPatchAsync([change], saveTo); + } + + e.Handled = true; + }; + + var menu = new ContextMenu(); + menu.Items.Add(openWithMerger); + menu.Items.Add(explore); + menu.Items.Add(new MenuItem { Header = "-" }); + menu.Items.Add(history); + menu.Items.Add(blame); + menu.Items.Add(patch); + 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(change.Path); + ev.Handled = true; + }; + + 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 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(change.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 async void OnCommitListKeyDown(object sender, KeyEventArgs e) { if (DataContext is ViewModels.CommitDetail detail && @@ -48,11 +328,7 @@ namespace SourceGit.Views private void OnChangeContextRequested(object sender, ContextRequestedEventArgs e) { if (DataContext is ViewModels.CommitDetail detail && sender is Grid { DataContext: Models.Change change } grid) - { - var menu = detail.CreateChangeContextMenu(change); - menu?.Open(grid); - } - + CreateChangeContextMenu(change)?.Open(grid); e.Handled = true; } } diff --git a/src/Views/RevisionFileTreeView.axaml.cs b/src/Views/RevisionFileTreeView.axaml.cs index e73e9efb..6c6dae36 100644 --- a/src/Views/RevisionFileTreeView.axaml.cs +++ b/src/Views/RevisionFileTreeView.axaml.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; using Avalonia; using Avalonia.Collections; @@ -8,6 +9,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Platform.Storage; using Avalonia.VisualTree; namespace SourceGit.Views @@ -139,7 +141,27 @@ namespace SourceGit.Views var detailView = this.FindAncestorOfType(); if (detailView is { DataContext: ViewModels.CommitDetail detail }) { - await detail.SaveRevisionFile(file); + 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; } } @@ -308,10 +330,14 @@ namespace SourceGit.Views private void OnTreeNodeContextRequested(object sender, ContextRequestedEventArgs e) { - if (DataContext is ViewModels.CommitDetail vm && + if (DataContext is ViewModels.CommitDetail { Repository: ViewModels.Repository repo, Commit: Models.Commit commit } vm && sender is Grid { DataContext: ViewModels.RevisionFileTreeNode { Backend: { } obj } } grid) { - var menu = vm.CreateRevisionFileContextMenu(obj); + var menu = obj.Type switch + { + Models.ObjectType.Tree => CreateRevisionFileContextMenuByFolder(repo, vm, commit, obj.Path), + _ => CreateRevisionFileContextMenu(repo, vm, commit, obj), + }; menu.Open(grid); } @@ -387,6 +413,261 @@ namespace SourceGit.Views }); } + public 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; + } + + public 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("OpenWith"); + openWith.Icon = App.CreateMenuIcon("Icons.OpenWith"); + openWith.Tag = OperatingSystem.IsMacOS() ? "⌘+O" : "Ctrl+O"; + openWith.IsEnabled = file.Type == Models.ObjectType.Blob; + openWith.Click += async (_, ev) => + { + await vm.OpenRevisionFileWithDefaultEditorAsync(file.Path); + ev.Handled = true; + }; + + 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, 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 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 _tree = []; private bool _disableSelectionChangingEvent = false; private List _searchResult = [];