From 9c5158c46436f3e84649b8205194d3ca43d09057 Mon Sep 17 00:00:00 2001 From: Igor Velikorossov Date: Sun, 18 Jun 2023 21:22:21 +1000 Subject: [PATCH] Allow "save as..." multiple files --- GitCommands/Git/GitModule.cs | 2 +- GitUI/CommandsDialogs/RevisionDiffControl.cs | 67 +++++---- .../CommandsDialogs/RevisionDiffController.cs | 109 ++++++++++++++- Plugins/GitUIPluginInterfaces/IGitModule.cs | 2 + .../RevisionDiffControllerTests.cs | 127 +++++++++++++++++- 5 files changed, 280 insertions(+), 27 deletions(-) diff --git a/GitCommands/Git/GitModule.cs b/GitCommands/Git/GitModule.cs index 937ebcd8aa6..f390b3da251 100644 --- a/GitCommands/Git/GitModule.cs +++ b/GitCommands/Git/GitModule.cs @@ -636,7 +636,7 @@ public void SaveBlobAs(string saveAs, string blob) } } - using var stream = File.Create(saveAs); + using FileStream stream = File.Create(saveAs); stream.Write(blobData, 0, blobData.Length); } diff --git a/GitUI/CommandsDialogs/RevisionDiffControl.cs b/GitUI/CommandsDialogs/RevisionDiffControl.cs index 72f75a5f0bf..49ace5f9352 100644 --- a/GitUI/CommandsDialogs/RevisionDiffControl.cs +++ b/GitUI/CommandsDialogs/RevisionDiffControl.cs @@ -34,7 +34,7 @@ public partial class RevisionDiffControl : GitModuleControl private IRevisionGridUpdate? _revisionGridUpdate; private Func? _pathFilter; private RevisionFileTreeControl? _revisionFileTree; - private readonly IRevisionDiffController _revisionDiffController = new RevisionDiffController(); + private readonly IRevisionDiffController _revisionDiffController; private readonly IFileStatusListContextMenuController _revisionDiffContextMenuController; private readonly IFullPathResolver _fullPathResolver; private readonly IFindFilePredicateProvider _findFilePredicateProvider; @@ -57,6 +57,7 @@ public RevisionDiffControl() InitializeComplete(); HotkeysEnabled = true; _fullPathResolver = new FullPathResolver(() => Module.WorkingDir); + _revisionDiffController = new RevisionDiffController(() => Module, _fullPathResolver); _findFilePredicateProvider = new FindFilePredicateProvider(); _gitRevisionTester = new GitRevisionTester(_fullPathResolver); _revisionDiffContextMenuController = new FileStatusListContextMenuController(); @@ -710,7 +711,6 @@ private void UpdateStatusOfMenuItems() diffOpenRevisionFileWithToolStripMenuItem.Visible = _revisionDiffController.ShouldShowMenuOpenRevision(selectionInfo); diffOpenRevisionFileWithToolStripMenuItem.Enabled = _revisionDiffController.ShouldShowMenuShowInFileTree(selectionInfo); saveAsToolStripMenuItem1.Visible = _revisionDiffController.ShouldShowMenuSaveAs(selectionInfo); - saveAsToolStripMenuItem1.Enabled = _revisionDiffController.ShouldShowMenuShowInFileTree(selectionInfo); openContainingFolderToolStripMenuItem.Visible = _revisionDiffController.ShouldShowMenuShowInFolder(selectionInfo); diffEditWorkingDirectoryFileToolStripMenuItem.Visible = _revisionDiffController.ShouldShowMenuEditWorkingDirectoryFile(selectionInfo); diffDeleteFileToolStripMenuItem.Text = ResourceManager.TranslatedStrings.GetDeleteFile(selectionInfo.SelectedGitItemCount); @@ -1169,31 +1169,52 @@ private void InitResetFileToToolStripMenuItem() private void saveAsToolStripMenuItem1_Click(object sender, EventArgs e) { - FileStatusItem? item = DiffFiles.SelectedItem; - if (item is null) - { - return; - } + List files = DiffFiles.SelectedItems.ToList(); - var fullName = _fullPathResolver.Resolve(item.Item.Name); - using SaveFileDialog fileDialog = - new() + Func userSelection = default; + if (files.Count == 1) + { + userSelection = (fullName) => { - InitialDirectory = Path.GetDirectoryName(fullName), - FileName = Path.GetFileName(fullName), - DefaultExt = Path.GetExtension(fullName), - AddExtension = true - }; - fileDialog.Filter = - _saveFileFilterCurrentFormat.Text + " (*." + - fileDialog.DefaultExt + ")|*." + - fileDialog.DefaultExt + - "|" + _saveFileFilterAllFiles.Text + " (*.*)|*.*"; + using SaveFileDialog dialog = + new() + { + InitialDirectory = Path.GetDirectoryName(fullName), + FileName = Path.GetFileName(fullName), + DefaultExt = Path.GetExtension(fullName), + AddExtension = true + }; + dialog.Filter = $"{_saveFileFilterCurrentFormat.Text}(*.{dialog.DefaultExt})|*.{dialog.DefaultExt}|{_saveFileFilterAllFiles.Text}(*.*)|*.*"; + + if (dialog.ShowDialog(this) == DialogResult.OK) + { + return dialog.FileName; + } - if (fileDialog.ShowDialog(this) == DialogResult.OK) + return null; + }; + } + else if (files.Count > 1) { - Module.SaveBlobAs(fileDialog.FileName, $"{item.SecondRevision.Guid}:\"{item.Item.Name}\""); + userSelection = (baseSourceDirectory) => + { + using FolderBrowserDialog dialog = + new() + { + InitialDirectory = baseSourceDirectory, + ShowNewFolderButton = true, + }; + + if (dialog.ShowDialog(this) == DialogResult.OK) + { + return dialog.SelectedPath; + } + + return null; + }; } + + _revisionDiffController.SaveFiles(files, userSelection); } private bool DeleteSelectedFiles() @@ -1286,7 +1307,7 @@ private void diffResetSubmoduleChanges_Click(object sender, EventArgs e) } RequestRefresh(); - } + } private void diffUpdateSubmoduleMenuItem_Click(object sender, EventArgs e) { diff --git a/GitUI/CommandsDialogs/RevisionDiffController.cs b/GitUI/CommandsDialogs/RevisionDiffController.cs index a5623171654..0bf7ef06391 100644 --- a/GitUI/CommandsDialogs/RevisionDiffController.cs +++ b/GitUI/CommandsDialogs/RevisionDiffController.cs @@ -1,9 +1,13 @@ -using GitUIPluginInterfaces; +using System.Diagnostics; +using GitCommands; +using GitUI.UserControls; +using GitUIPluginInterfaces; namespace GitUI.CommandsDialogs { public interface IRevisionDiffController { + void SaveFiles(List files, Func userSelection); bool ShouldShowMenuBlame(ContextMenuSelectionInfo selectionInfo); bool ShouldShowMenuCherryPick(ContextMenuSelectionInfo selectionInfo); bool ShouldShowMenuEditWorkingDirectoryFile(ContextMenuSelectionInfo selectionInfo); @@ -74,6 +78,96 @@ public sealed class ContextMenuSelectionInfo public sealed class RevisionDiffController : IRevisionDiffController { + private readonly Func _getModule; + private readonly IFullPathResolver _fullPathResolver; + + public RevisionDiffController(Func getModule, IFullPathResolver fullPathResolver) + { + _getModule = getModule; + _fullPathResolver = fullPathResolver; + } + + public void SaveFiles(List files, Func userSelection) + { + if (files is null) + { + throw new ArgumentNullException(nameof(files)); + } + + if (files.Count == 0) + { + return; + } + + if (userSelection is null) + { + throw new ArgumentNullException(nameof(userSelection)); + } + + if (files.Count > 1) + { + SaveMultipleFiles(files); + return; + } + + SaveSingleFile(files[0]); + return; + + void SaveMultipleFiles(List selectedFiles) + { + // Derive the folder from the first selected file. + string firstItemFullName = _fullPathResolver.Resolve(selectedFiles.First().Item.Name); + string baseSourceDirectory = Path.GetDirectoryName(firstItemFullName).EnsureTrailingPathSeparator(); + + string selectedPath = userSelection(baseSourceDirectory); + if (selectedPath is null) + { + // User has cancelled the selection + return; + } + + Uri baseSourceDirectoryUri = new(baseSourceDirectory); + + foreach (FileStatusItem item in selectedFiles) + { + string selectedItemFullName = _fullPathResolver.Resolve(item.Item.Name); + string selectedItemSourceDirectory = Path.GetDirectoryName(selectedItemFullName).EnsureTrailingPathSeparator(); + + string targetDirectory; + if (selectedItemSourceDirectory == baseSourceDirectory) + { + targetDirectory = selectedPath; + } + else + { + Uri selectedItemUri = new(selectedItemSourceDirectory); + targetDirectory = Path.Combine(selectedPath, baseSourceDirectoryUri.MakeRelativeUri(selectedItemUri).OriginalString); + } + + // TODO: check target file exists. + // TODO: allow cancel the whole sequence + + string targetFileName = Path.Combine(targetDirectory, Path.GetFileName(selectedItemFullName)); + Debug.WriteLine($"Saving {selectedItemFullName} --> {targetFileName}"); + + GetModule().SaveBlobAs(targetFileName, $"{item.SecondRevision.Guid}:\"{item.Item.Name}\""); + } + } + + void SaveSingleFile(FileStatusItem item) + { + string fullName = _fullPathResolver.Resolve(item.Item.Name); + string selectedFileName = userSelection(fullName); + if (selectedFileName is null) + { + // User has cancelled the selection + return; + } + + GetModule().SaveBlobAs(selectedFileName, $"{item.SecondRevision.Guid}:\"{item.Item.Name}\""); + } + } + // The enabling of menu items is related to how the actions have been implemented public bool ShouldShowDifftoolMenus(ContextMenuSelectionInfo selectionInfo) @@ -92,7 +186,7 @@ public bool ShouldShowResetFileMenus(ContextMenuSelectionInfo selectionInfo) #region Main menu items public bool ShouldShowMenuSaveAs(ContextMenuSelectionInfo selectionInfo) { - return selectionInfo.SelectedGitItemCount == 1 && !selectionInfo.IsAnySubmodule + return selectionInfo.SelectedGitItemCount > 0 && !selectionInfo.IsAnySubmodule && !(selectionInfo.SelectedRevision?.IsArtificial ?? false) && !selectionInfo.IsDisplayOnlyDiff; } @@ -164,5 +258,16 @@ public bool ShouldShowMenuBlame(ContextMenuSelectionInfo selectionInfo) return ShouldShowMenuFileHistory(selectionInfo) && !selectionInfo.IsAnySubmodule; } #endregion + + private IGitModule GetModule() + { + var module = _getModule(); + if (module is null) + { + throw new ArgumentException($"Require a valid instance of {nameof(IGitModule)}"); + } + + return module; + } } } diff --git a/Plugins/GitUIPluginInterfaces/IGitModule.cs b/Plugins/GitUIPluginInterfaces/IGitModule.cs index a3d7dfcd892..1fcb7c07d75 100644 --- a/Plugins/GitUIPluginInterfaces/IGitModule.cs +++ b/Plugins/GitUIPluginInterfaces/IGitModule.cs @@ -144,5 +144,7 @@ public interface IGitModule string? GetDescribe(ObjectId commitId); (int totalCount, Dictionary countByName) GetCommitsByContributor(DateTime? since = null, DateTime? until = null); + + void SaveBlobAs(string saveAs, string blob); } } diff --git a/UnitTests/GitUI.Tests/CommandsDialogs/RevisionDiffControllerTests.cs b/UnitTests/GitUI.Tests/CommandsDialogs/RevisionDiffControllerTests.cs index 5260b6fd752..0a2d04e1a5a 100644 --- a/UnitTests/GitUI.Tests/CommandsDialogs/RevisionDiffControllerTests.cs +++ b/UnitTests/GitUI.Tests/CommandsDialogs/RevisionDiffControllerTests.cs @@ -1,13 +1,138 @@ using FluentAssertions; +using GitCommands; using GitUI.CommandsDialogs; +using GitUI.UserControls; using GitUIPluginInterfaces; +using NSubstitute; namespace GitUITests.CommandsDialogs { + [SetCulture("en-US")] + [SetUICulture("en-US")] [TestFixture] public class RevisionDiffControllerTests { - private readonly RevisionDiffController _controller = new(); + private IGitModule _module; + private IFullPathResolver _fullPathResolver; + private RevisionDiffController _controller; + + [SetUp] + public void Setup() + { + _module = Substitute.For(); + _fullPathResolver = Substitute.For(); + _controller = new(() => _module, _fullPathResolver); + } + + [Test] + public void SaveFiles_should_throw_if_files_null() + { + ((Action)(() => _controller.SaveFiles(files: null, userSelection: null))).Should() + .Throw() + .WithMessage("Value cannot be null. (Parameter 'files')"); + } + + [Test] + public void SaveFiles_should_not_throw_if_userSelection_null_when_files_empty() + { + List files = new(); + + ((Action)(() => _controller.SaveFiles(files, userSelection: null))).Should().NotThrow(); + } + + [Test] + public void SaveFiles_should_throw_if_userSelection_null_when_files_notnull() + { + List files = new() + { + new(default, new(ObjectId.Random()), new("")) + }; + + ((Action)(() => _controller.SaveFiles(files, userSelection: null))).Should() + .Throw() + .WithMessage("Value cannot be null. (Parameter 'userSelection')"); + } + + [Test] + public void SaveFiles_should_not_save_single_file_if_selection_cancelled() + { + FileStatusItem item = new(default, new(ObjectId.Random()), new("")); + List files = new() + { + item + }; + + // User cancelled the dialog + Func userSelection = (_) => null; + + _controller.SaveFiles(files, userSelection); + + _fullPathResolver.Received(1).Resolve(item.Item.Name); + _module.Received(0).SaveBlobAs(Arg.Any(), Arg.Any()); + } + + [Test] + public void SaveFiles_should_save_single_file() + { + FileStatusItem item = new(default, new(ObjectId.Random()), new("")); + List files = new() + { + item + }; + + Func userSelection = (_) => "c:\\temp\\file.txt"; + + _controller.SaveFiles(files, userSelection); + + _fullPathResolver.Received(1).Resolve(item.Item.Name); + _module.Received(1).SaveBlobAs("c:\\temp\\file.txt", Arg.Any()); + } + + [Test] + public void SaveFiles_should_not_save_multi_files_if_selection_cancelled() + { + FileStatusItem item1 = new(default, new(ObjectId.Random()), new("item1")); + FileStatusItem item2 = new(default, new(ObjectId.Random()), new("item2")); + List files = new() + { + item1, + item2 + }; + + // User cancelled the dialog + Func userSelection = (_) => null; + + _controller.SaveFiles(files, userSelection); + + _fullPathResolver.Received(1).Resolve(item1.Item.Name); + _fullPathResolver.Received(0).Resolve(item2.Item.Name); + _module.Received(0).SaveBlobAs(Arg.Any(), Arg.Any()); + } + + [Test] + public void SaveFiles_should_save_multi_files_same_folder() + { + FileStatusItem item1 = new(default, new(ObjectId.Random()), new("item1")); + FileStatusItem item2 = new(default, new(ObjectId.Random()), new("item2")); + List files = new() + { + item1, + item2 + }; + + _fullPathResolver.Resolve(item1.Item.Name).Returns(x => "c:\\temp\\item1.txt"); + _fullPathResolver.Resolve(item2.Item.Name).Returns(x => "c:\\temp\\item2.txt"); + + Func userSelection = (_) => "c:\\temp"; + + _controller.SaveFiles(files, userSelection); + + _fullPathResolver.Received(2).Resolve(item1.Item.Name); + _fullPathResolver.Received(1).Resolve(item2.Item.Name); + _module.ReceivedWithAnyArgs(2).SaveBlobAs(default, default); + _module.Received(1).SaveBlobAs("c:\\temp\\item1.txt", Arg.Any()); + _module.Received(1).SaveBlobAs("c:\\temp\\item2.txt", Arg.Any()); + } private static ContextMenuSelectionInfo CreateContextMenuSelectionInfo(GitRevision selectedRevision = null, bool isDisplayOnlyDiff = false,