Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions GitHubExtension.Test/Controls/LocalCloneTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json;
using GitHubExtension.Controls.Forms;
using GitHubExtension.DataManager;
using GitHubExtension.DataManager.Data;
using GitHubExtension.Helpers;
using Moq;

namespace GitHubExtension.Test.Controls;

[TestClass]
public class LocalCloneTests
{
private static Mock<IResources> CreateResources()
{
var resources = new Mock<IResources>();
resources.Setup(x => x.GetResource(It.IsAny<string>(), null)).Returns<string, object>((key, _) => key);
return resources;
}

[TestMethod]
[TestCategory("Unit")]
public void ResolveCloneUrl_OwnerRepo_BuildsHttpsUrl()
{
var (cloneUrl, name) = RepositoryCloneManager.ResolveCloneUrl("octocat/Hello-World");

Assert.AreEqual("https://github.com/octocat/Hello-World.git", cloneUrl);
Assert.AreEqual("Hello-World", name);
}

[TestMethod]
[TestCategory("Unit")]
public void ResolveCloneUrl_FullUrl_UsedAsIsAndStripsGitSuffix()
{
var (cloneUrl, name) = RepositoryCloneManager.ResolveCloneUrl("https://github.com/octocat/Hello-World.git");

Assert.AreEqual("https://github.com/octocat/Hello-World.git", cloneUrl);
Assert.AreEqual("Hello-World", name);
}

[TestMethod]
[TestCategory("Unit")]
public void ResolveCloneUrl_HtmlUrl_DerivesRepositoryName()
{
var (cloneUrl, name) = RepositoryCloneManager.ResolveCloneUrl("https://github.com/octocat/Hello-World");

Assert.AreEqual("https://github.com/octocat/Hello-World", cloneUrl);
Assert.AreEqual("Hello-World", name);
}

[TestMethod]
[TestCategory("Unit")]
public async Task CloneRepositoryAsync_GitMissing_Throws()
{
var gitService = new Mock<IGitService>();
gitService.Setup(x => x.IsGitInstalled()).Returns(false);
var settingsStore = new Mock<ICloneSettingsStore>();
var manager = new RepositoryCloneManager(gitService.Object, settingsStore.Object);

await Assert.ThrowsExceptionAsync<InvalidOperationException>(
() => manager.CloneRepositoryAsync("octocat/Hello-World"));
}

[TestMethod]
[TestCategory("Unit")]
public async Task CloneRepositoryAsync_UsesConfiguredBaseDirectory_AndReturnsDestination()
{
var baseDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
var gitService = new Mock<IGitService>();
gitService.Setup(x => x.IsGitInstalled()).Returns(true);
string? capturedUrl = null;
string? capturedDestination = null;
gitService
.Setup(x => x.CloneAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback<string, string, CancellationToken>((url, destination, _) =>
{
capturedUrl = url;
capturedDestination = destination;
})
.Returns(Task.CompletedTask);
var settingsStore = new Mock<ICloneSettingsStore>();
settingsStore.Setup(x => x.GetCloneBaseDirectoryAsync()).ReturnsAsync(baseDirectory);
var manager = new RepositoryCloneManager(gitService.Object, settingsStore.Object);

var result = await manager.CloneRepositoryAsync("octocat/Hello-World");

Assert.AreEqual(Path.Combine(baseDirectory, "Hello-World"), result);
Assert.AreEqual("https://github.com/octocat/Hello-World.git", capturedUrl);
Assert.AreEqual(Path.Combine(baseDirectory, "Hello-World"), capturedDestination);
}

[TestMethod]
[TestCategory("Unit")]
public async Task CloneRepositoryAsync_ExplicitTargetDirectory_OverridesSettings()
{
var explicitDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
var gitService = new Mock<IGitService>();
gitService.Setup(x => x.IsGitInstalled()).Returns(true);
gitService
.Setup(x => x.CloneAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var settingsStore = new Mock<ICloneSettingsStore>();
var manager = new RepositoryCloneManager(gitService.Object, settingsStore.Object);

var result = await manager.CloneRepositoryAsync("octocat/Hello-World", explicitDirectory);

Assert.AreEqual(Path.Combine(explicitDirectory, "Hello-World"), result);
settingsStore.Verify(x => x.GetCloneBaseDirectoryAsync(), Times.Never);
}

[TestMethod]
[TestCategory("Unit")]
public void CloneSettingsStore_DefaultBaseDirectory_IsUnderUserProfile()
{
var store = new CloneSettingsStore();

var defaultDirectory = store.GetDefaultCloneBaseDirectory();

var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
Assert.AreEqual(Path.Combine(userProfile, "source", "repos"), defaultDirectory);
}

[TestMethod]
[TestCategory("Unit")]
public void CloneRepositoryForm_RendersValidTemplateJson()
{
var resources = CreateResources();
var cloneManager = new Mock<IRepositoryCloneManager>();
var settingsStore = new Mock<ICloneSettingsStore>();
settingsStore.Setup(x => x.GetDefaultCloneBaseDirectory()).Returns("C:\\src");

var form = new CloneRepositoryForm(cloneManager.Object, settingsStore.Object, resources.Object);

var json = form.TemplateJson;
Assert.IsFalse(string.IsNullOrWhiteSpace(json));
using var document = JsonDocument.Parse(json);
Assert.AreEqual("AdaptiveCard", document.RootElement.GetProperty("type").GetString());
}
}
4 changes: 2 additions & 2 deletions GitHubExtension.Test/Controls/SearchPagesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public void SearchPagesCreate_CreatesPagesForBothTypes()
Assert.IsNotNull(pullRequestsSearchPage);

search.Setup(x => x.Type).Returns(SearchType.Repositories);
var repositoriesSearchPage = new RepositoriesSearchPage(search.Object, cacheDataManager.Object, resources.Object);
var repositoriesSearchPage = new RepositoriesSearchPage(search.Object, cacheDataManager.Object, resources.Object, new Mock<IRepositoryCloneManager>().Object);
Assert.IsNotNull(repositoriesSearchPage);
}

Expand All @@ -64,7 +64,7 @@ public void GetItemsFromRepositoriesSearchPage_ReturnsExpectedItems()
{
var (cacheDataManager, resources, search) = CreateCommonMocks(SearchType.Repositories, "test search string type:repository");

var page = new RepositoriesSearchPage(search.Object, cacheDataManager.Object, resources.Object);
var page = new RepositoriesSearchPage(search.Object, cacheDataManager.Object, resources.Object, new Mock<IRepositoryCloneManager>().Object);

var repo1 = new Mock<IRepository>();
var repo2 = new Mock<IRepository>();
Expand Down
2 changes: 1 addition & 1 deletion GitHubExtension.Test/Controls/TopLevelSearchesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public async Task Integration_AddNewTopLevelCommand(string searchString, string
var autoMergeManager = new Mock<IGitHubAutoMergeManager>().Object;
var mutationMediator = new MutationMediator();
var mutationCommandsFactory = new MutationCommandsFactory(mutationManager, createManager, autoMergeManager, mutationMediator, resources);
var searchPageFactory = new SearchPageFactory(mockCacheDataManager, persistentDataManager, resources, mediator, mutationCommandsFactory, mutationMediator);
var searchPageFactory = new SearchPageFactory(mockCacheDataManager, persistentDataManager, resources, mediator, mutationCommandsFactory, mutationMediator, new Mock<IRepositoryCloneManager>().Object);

var addSearchForm = new SaveSearchForm(persistentDataManager, resources, mediator);

Expand Down
4 changes: 3 additions & 1 deletion GitHubExtension.Test/Helpers/TestSetupHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ public static GitHubExtensionCommandsProvider CreateGitHubExtensionCommandsProvi
var searchDiscussionsPage = new DiscussionsPage(mockDiscussionsDataManager.Object, mockResources, "Search discussions", string.Empty);
var mockProjectsDataManager = new Mock<IProjectsDataManager>();
var projectsPage = new ProjectsPage(mockProjectsDataManager.Object, mockResources);
return new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, mockDeveloperIdProvider, persistentDataManager, mockResources, searchPageFactory, savedSearchesMediator, mockAuthenticationMediator, notificationsMediator, mockCreateManager, workflowRunsPage, mockWorkflowRunsDataManager.Object, myDiscussionsPage, searchDiscussionsPage, projectsPage);
var mockCloneManager = new Mock<IRepositoryCloneManager>().Object;
var mockCloneSettingsStore = new Mock<ICloneSettingsStore>().Object;
return new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, mockDeveloperIdProvider, persistentDataManager, mockResources, searchPageFactory, savedSearchesMediator, mockAuthenticationMediator, notificationsMediator, mockCreateManager, workflowRunsPage, mockWorkflowRunsDataManager.Object, myDiscussionsPage, searchDiscussionsPage, projectsPage, mockCloneManager, mockCloneSettingsStore);
}
}
48 changes: 48 additions & 0 deletions GitHubExtension/Controls/Commands/CloneRepositoryCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using GitHubExtension.DataManager;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Serilog;

namespace GitHubExtension.Controls.Commands;

// Clones a repository from a list item into the configured clone base directory,
// showing a success or error toast. The clone runs synchronously within Invoke
// so the toast reflects the final outcome.
internal sealed partial class CloneRepositoryCommand : InvokableCommand
{
private static readonly ILogger _log = Log.ForContext("SourceContext", nameof(CloneRepositoryCommand));

private readonly IRepository _repository;
private readonly IRepositoryCloneManager _cloneManager;
private readonly IResources _resources;

internal CloneRepositoryCommand(IRepository repository, IRepositoryCloneManager cloneManager, IResources resources)
{
_repository = repository;
_cloneManager = cloneManager;
_resources = resources;
Name = resources.GetResource("Commands_CloneRepository");
Icon = new IconInfo("\uE896");
}

public override CommandResult Invoke()
{
try
{
var cloneSource = string.IsNullOrWhiteSpace(_repository.CloneUrl) ? _repository.FullName : _repository.CloneUrl;
_cloneManager.CloneRepositoryAsync(cloneSource).GetAwaiter().GetResult();
ToastHelper.ShowSuccessToast(_resources.GetResource("Message_CloneRepository_Success"));
}
catch (Exception ex)
{
_log.Error(ex, "Failed to clone repository.");
ToastHelper.ShowErrorToast(_resources.GetResource("Message_CloneRepository_Error"));
}

return CommandResult.KeepOpen();
}
}
73 changes: 73 additions & 0 deletions GitHubExtension/Controls/Forms/CloneRepositoryForm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json;
using System.Text.Json.Nodes;
using GitHubExtension.DataManager;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;

namespace GitHubExtension.Controls.Forms;

public sealed partial class CloneRepositoryForm : FormContent, IGitHubForm
{
private readonly IRepositoryCloneManager _cloneManager;
private readonly ICloneSettingsStore _settingsStore;
private readonly IResources _resources;
private readonly string _initialRepository;

public event EventHandler<bool>? LoadingStateChanged;

public event EventHandler<FormSubmitEventArgs>? FormSubmitted;

public CloneRepositoryForm(IRepositoryCloneManager cloneManager, ICloneSettingsStore settingsStore, IResources resources, string initialRepository = "")
{
_cloneManager = cloneManager;
_settingsStore = settingsStore;
_resources = resources;
_initialRepository = initialRepository;
}

public Dictionary<string, string> TemplateSubstitutions => new()
{
{ "{{CloneRepositoryFormTitle}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Clone_Title")) },
{ "{{RepositoryLabel}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Clone_RepositoryLabel")) },
{ "{{RepositoryPlaceholder}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Clone_RepositoryPlaceholder")) },
{ "{{RepositoryErrorMessage}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Clone_RepositoryError")) },
{ "{{RepositoryValue}}", JsonSerializer.Serialize(_initialRepository) },
{ "{{TargetDirectoryLabel}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Clone_TargetDirectoryLabel")) },
{ "{{TargetDirectoryPlaceholder}}", JsonSerializer.Serialize(_settingsStore.GetDefaultCloneBaseDirectory()) },
{ "{{CloneRepositoryActionTitle}}", JsonSerializer.Serialize(_resources.GetResource("Forms_Clone_Action")) },
};

public override string TemplateJson => TemplateHelper.LoadTemplateJsonFromTemplateName("CloneRepository", TemplateSubstitutions);

public override ICommandResult SubmitForm(string? inputs, string data)
{
LoadingStateChanged?.Invoke(this, true);
_ = SubmitInternalAsync(inputs);
return CommandResult.KeepOpen();
}

private async Task SubmitInternalAsync(string? inputs)
{
try
{
var payload = JsonNode.Parse(inputs ?? string.Empty) ?? throw new InvalidOperationException("No input provided.");
var repository = payload["Repository"]?.ToString() ?? string.Empty;
var targetDirectory = payload["TargetDirectory"]?.ToString() ?? string.Empty;

await _cloneManager.CloneRepositoryAsync(repository, targetDirectory);

LoadingStateChanged?.Invoke(this, false);
FormSubmitted?.Invoke(this, new FormSubmitEventArgs(true, null));
}
catch (Exception ex)
{
LoadingStateChanged?.Invoke(this, false);
FormSubmitted?.Invoke(this, new FormSubmitEventArgs(false, ex));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
// See the LICENSE file in the project root for more information.

using GitHubExtension.Controls.Commands;
using GitHubExtension.DataManager;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;

namespace GitHubExtension.Controls.Pages;

public sealed partial class RepositoriesSearchPage(ISearch search, ICacheDataManager cacheDataManager, IResources resources)
public sealed partial class RepositoriesSearchPage(ISearch search, ICacheDataManager cacheDataManager, IResources resources, IRepositoryCloneManager cloneManager)
: SearchPage<IRepository>(search, cacheDataManager, resources)
{
private readonly IRepositoryCloneManager _cloneManager = cloneManager;

protected override ListItem GetListItem(IRepository item)
{
return new ListItem(new LinkCommand(item, Resources))
Expand All @@ -22,6 +25,7 @@ protected override ListItem GetListItem(IRepository item)
{
new(new CopyCommand(item.HtmlUrl, $"{Resources.GetResource("Commands_CopyURL")}", Resources)),
new(new CopyCommand(item.CloneUrl, $"{Resources.GetResource("Commands_Copy_CloneUrl")}", Resources)),
new(new CloneRepositoryCommand(item, _cloneManager, Resources)),
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using GitHubExtension.Controls.Commands;
using GitHubExtension.Controls.Forms;
using GitHubExtension.DataManager;
using GitHubExtension.DataModel.Enums;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions;
Expand All @@ -19,15 +20,17 @@ public class SearchPageFactory : ISearchPageFactory
private readonly SavedSearchesMediator _savedSearchesMediator;
private readonly MutationCommandsFactory _mutationCommandsFactory;
private readonly MutationMediator _mutationMediator;
private readonly IRepositoryCloneManager _cloneManager;

public SearchPageFactory(ICacheDataManager cacheDataManager, ISearchRepository searchRepository, IResources resources, SavedSearchesMediator savedSearchesMediator, MutationCommandsFactory mutationCommandsFactory, MutationMediator mutationMediator)
public SearchPageFactory(ICacheDataManager cacheDataManager, ISearchRepository searchRepository, IResources resources, SavedSearchesMediator savedSearchesMediator, MutationCommandsFactory mutationCommandsFactory, MutationMediator mutationMediator, IRepositoryCloneManager cloneManager)
{
_cacheDataManager = cacheDataManager;
_searchRepository = searchRepository;
_resources = resources;
_savedSearchesMediator = savedSearchesMediator;
_mutationCommandsFactory = mutationCommandsFactory;
_mutationMediator = mutationMediator;
_cloneManager = cloneManager;
}

private ListPage CreatePageForSearch(ISearch search)
Expand All @@ -36,7 +39,7 @@ private ListPage CreatePageForSearch(ISearch search)
{
SearchType.PullRequests => new PullRequestsSearchPage(search, _cacheDataManager, _resources, _mutationCommandsFactory, _mutationMediator),
SearchType.Issues => new IssuesSearchPage(search, _cacheDataManager, _resources, _mutationCommandsFactory, _mutationMediator),
SearchType.Repositories => new RepositoriesSearchPage(search, _cacheDataManager, _resources),
SearchType.Repositories => new RepositoriesSearchPage(search, _cacheDataManager, _resources, _cloneManager),
_ => new CombinedSearchPage(search, _cacheDataManager, _resources, _mutationCommandsFactory, _mutationMediator),
};
}
Expand Down
Loading
Loading