Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Unified Clone / Open from GitHub UI #1919

Merged
merged 46 commits into from
Sep 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b2f0679
Use clone dialog as OpenFromUrl UI
jcansdale Sep 10, 2018
5e955d6
Add File / Open / Open from GitHub... button
jcansdale Sep 11, 2018
f2ed1fd
WIP: Allow user to open from the clone dialog
jcansdale Sep 12, 2018
732f9d2
Add RepositoryCloneService.CloneOrOpenRepository
jcansdale Sep 12, 2018
ba3bdfa
Add ITeamExplorerServices.OpenRepository
jcansdale Sep 12, 2018
1726986
Use CloneOrOpenRepository in OpenFromUrlCommand
jcansdale Sep 12, 2018
5295642
Open repository after cloning
jcansdale Sep 12, 2018
f8d0c7b
Don't attempt to open folder on Visual Studio 2015
jcansdale Sep 13, 2018
f10bf85
Only enable Open when a repository is selected
jcansdale Sep 13, 2018
b82ac27
Fix failing tests
jcansdale Sep 13, 2018
43fd57d
Only show path error when file is in way
jcansdale Sep 13, 2018
2aa841d
Prepopulate URL from clipboard/command args
jcansdale Sep 13, 2018
a47cc29
Fix CA errors
jcansdale Sep 13, 2018
f13312e
Change dialog title to Open from GitHub
jcansdale Sep 18, 2018
9ec30fb
Return Url instead of IRepositoryModel
jcansdale Sep 18, 2018
efcf29d
Make CloneOrOpenRepository accept URL
jcansdale Sep 18, 2018
b361468
Simplify OpenFromUrlCommand
jcansdale Sep 18, 2018
fff3255
Merge branch 'master' into feature/open-from-uri-clone-ui
jcansdale Sep 19, 2018
bb181eb
Merge branch 'master' into feature/open-from-uri-clone-ui
Sep 21, 2018
fdca558
Revert change to default clone/open tab
jcansdale Sep 24, 2018
95f8a01
Merge branch 'master' into feature/open-from-uri-clone-ui
jcansdale Sep 25, 2018
b147324
Don't OpenRepository after cloning
jcansdale Sep 25, 2018
bde9863
Give ShowCloneDialog url a default of null
jcansdale Sep 25, 2018
698196c
Make CloneOrOpenRepository take CloneDialogResult
jcansdale Sep 25, 2018
c37522b
Don't run XliffTasks targets during NCrunch build
jcansdale Sep 25, 2018
9cd6c4c
Merge branch 'fixes/ncrunch-XliffTasks' into feature/open-from-uri-cl…
jcansdale Sep 25, 2018
451a5b0
Only increment NumberOfClones during clone
jcansdale Sep 25, 2018
d91c9ab
Increment new counters in CloneOrOpenRepository
jcansdale Sep 25, 2018
af55f6a
Add tests for NumberOf*Opens counters
jcansdale Sep 25, 2018
8067273
Increment NumberOf*Opens counters
jcansdale Sep 25, 2018
9f8757a
Update DestinationAlreadyExists warning
jcansdale Sep 25, 2018
3172217
Merge branch 'master' into feature/open-from-uri-clone-ui
jcansdale Sep 25, 2018
527b1d1
Sanity check local repository
jcansdale Sep 26, 2018
3d2b580
Consolidate PathError into PathWarning
jcansdale Sep 26, 2018
09347ae
Fixing test
StanleyGoldman Sep 26, 2018
43ba772
Fixing another test
StanleyGoldman Sep 26, 2018
6de1bbb
Merge branch 'master' into feature/open-from-uri-clone-ui
StanleyGoldman Sep 26, 2018
f1ba0d2
Revert "Don't run XliffTasks targets during NCrunch build"
jcansdale Sep 26, 2018
7344458
Convert strings to resources
jcansdale Sep 27, 2018
d789c4e
Show warning when local clone already exists
jcansdale Sep 27, 2018
27e13d2
Refined wording of local path warnings
jcansdale Sep 27, 2018
43162db
Factor out magical strings
jcansdale Sep 27, 2018
5f2012a
Update Clone/Open enabled tests
jcansdale Sep 27, 2018
cfadc62
Add unit tests for ValidatePathWarning
jcansdale Sep 27, 2018
ca1a215
Update ErrorType.ClonedFailed message
jcansdale Sep 27, 2018
37e9acf
Merge branch 'master' into feature/open-from-uri-clone-ui
jcansdale Sep 27, 2018
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public RepositoryCloneViewModelDesigner()
}

public string Path { get; set; }
public string PathError { get; set; }
public string PathWarning { get; set; }
public int SelectedTabIndex { get; set; }
public string Title => null;
public IObservable<object> Done => null;
Expand Down
6 changes: 5 additions & 1 deletion src/GitHub.App/Services/DialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,13 @@ public DialogService(
this.showDialog = showDialog;
}

public async Task<CloneDialogResult> ShowCloneDialog(IConnection connection)
public async Task<CloneDialogResult> ShowCloneDialog(IConnection connection, string url = null)
{
var viewModel = factory.CreateViewModel<IRepositoryCloneViewModel>();
if (url != null)
{
viewModel.UrlTab.Url = url;
}

if (connection != null)
{
Expand Down
69 changes: 55 additions & 14 deletions src/GitHub.App/Services/RepositoryCloneService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class RepositoryCloneService : IRepositoryCloneService
readonly IOperatingSystem operatingSystem;
readonly string defaultClonePath;
readonly IVSGitServices vsGitServices;
readonly ITeamExplorerServices teamExplorerServices;
readonly IGraphQLClientFactory graphqlFactory;
readonly IUsageTracker usageTracker;
ICompiledQuery<ViewerRepositoriesModel> readViewerRepositories;
Expand All @@ -42,11 +43,13 @@ public class RepositoryCloneService : IRepositoryCloneService
public RepositoryCloneService(
IOperatingSystem operatingSystem,
IVSGitServices vsGitServices,
ITeamExplorerServices teamExplorerServices,
IGraphQLClientFactory graphqlFactory,
IUsageTracker usageTracker)
{
this.operatingSystem = operatingSystem;
this.vsGitServices = vsGitServices;
this.teamExplorerServices = teamExplorerServices;
this.graphqlFactory = graphqlFactory;
this.usageTracker = usageTracker;

Expand Down Expand Up @@ -103,6 +106,54 @@ public async Task<ViewerRepositoriesModel> ReadViewerRepositories(HostAddress ad
return result;
}

/// <inheritdoc/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering whether we need both Clone and CloneOrOpen? Wouldn't Clone and Open make more sense? The decision of which to call would typically be made at a higher level I would have thought as we're checking whether the repository already exists in order to show/hide the Open button.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated this as discussed in Slack. There is now a CloneOrOpenRepository(CloneDialogResult) method which is indented to be used when the clone dialog returns a non-null CloneDialogResult.

While updating this I discovered that the create repository functionality is also calling CloneRepository. I this means that for every NumberOfReposCreated incremented, the NumberOfClones counter will be incremented as well. Oops! //cc @telliott27

public async Task CloneOrOpenRepository(
CloneDialogResult cloneDialogResult,
object progress = null)
{
Guard.ArgumentNotNull(cloneDialogResult, nameof(cloneDialogResult));

var repositoryPath = cloneDialogResult.Path;
var url = cloneDialogResult.Url;

if (DestinationFileExists(repositoryPath))
{
throw new InvalidOperationException("Can't clone or open a repository because a file exists at: " + repositoryPath);
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be checking whether the repository at this path has the same clone URL? I'm thinking of the case where e.g. the user wants to clone a fork and they already have the parent repository at this location.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! In fact there are a few scenarios we should be checking for.

  1. That a repository exists at the target path
  2. That the repository has an origin remote
  3. That the repository's remote matches the target URL

I'll let you know when these checks land.

var repositoryUrl = url.ToRepositoryUrl();
var isDotCom = HostAddress.IsGitHubDotComUri(repositoryUrl);
if (DestinationDirectoryExists(repositoryPath))
{
teamExplorerServices.OpenRepository(repositoryPath);

if (isDotCom)
{
await usageTracker.IncrementCounter(x => x.NumberOfGitHubOpens);
}
else
{
await usageTracker.IncrementCounter(x => x.NumberOfEnterpriseOpens);
}
}
else
{
var cloneUrl = repositoryUrl.ToString();
await CloneRepository(cloneUrl, repositoryPath, progress).ConfigureAwait(true);

if (isDotCom)
{
await usageTracker.IncrementCounter(x => x.NumberOfGitHubClones);
}
else
{
await usageTracker.IncrementCounter(x => x.NumberOfEnterpriseClones);
}
}

teamExplorerServices.ShowHomePage();
}

/// <inheritdoc/>
public async Task CloneRepository(
string cloneUrl,
Expand All @@ -121,21 +172,8 @@ public async Task CloneRepository(
try
{
await vsGitServices.Clone(cloneUrl, repositoryPath, true, progress);

await usageTracker.IncrementCounter(x => x.NumberOfClones);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is being called by CreateRepository here:

.Select(repository => cloneService.CloneRepository(repository.CloneUrl, Path.Combine(directory, repository.Name)))


var repositoryUrl = new UriString(cloneUrl).ToRepositoryUrl();
var isDotCom = HostAddress.IsGitHubDotComUri(repositoryUrl);
if (isDotCom)
{
await usageTracker.IncrementCounter(x => x.NumberOfGitHubClones);
}
else
{
// If it isn't a GitHub URL, assume it's an Enterprise URL
await usageTracker.IncrementCounter(x => x.NumberOfEnterpriseClones);
}

if (repositoryPath.StartsWith(DefaultClonePath, StringComparison.OrdinalIgnoreCase))
{
// Count the number of times users clone into the Default Repository Location
Expand All @@ -150,7 +188,10 @@ public async Task CloneRepository(
}

/// <inheritdoc/>
public bool DestinationExists(string path) => Directory.Exists(path) || File.Exists(path);
public bool DestinationDirectoryExists(string path) => operatingSystem.Directory.DirectoryExists(path);

/// <inheritdoc/>
public bool DestinationFileExists(string path) => operatingSystem.File.Exists(path);

string GetLocalClonePathFromGitProvider(string fallbackPath)
{
Expand Down
4 changes: 2 additions & 2 deletions src/GitHub.App/Services/StandardUserErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public enum ErrorType
CannotDropFolder,
CannotDropFolderUnauthorizedAccess,
ClipboardFailed,
ClonedFailed,
CloneOrOpenFailed,
CloneFailedNotLoggedIn,
CommitCreateFailed,
CommitRevertFailed,
Expand Down Expand Up @@ -123,7 +123,7 @@ public static class StandardUserErrors
},
{ ErrorType.ClipboardFailed, Map(Defaults("Failed to copy text to the clipboard.")) },
{
ErrorType.ClonedFailed, Map(Defaults("Failed to clone the repository '{0}'", "Email [email protected] if you continue to have problems."),
ErrorType.CloneOrOpenFailed, Map(Defaults("Failed to clone or open the repository '{0}'", "Email [email protected] if you continue to have problems."),
new[]
{
new Translation(@"fatal: bad config file line (\d+) in (.+)", "Failed to clone the repository '{0}'", @"The config file '$2' is corrupted at line $1. You may need to open the file and try to fix any errors."),
Expand Down
72 changes: 54 additions & 18 deletions src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Globalization;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using GitHub.App;
using GitHub.Extensions;
using GitHub.Logging;
using GitHub.Models;
Expand All @@ -23,19 +23,21 @@ public class RepositoryCloneViewModel : ViewModelBase, IRepositoryCloneViewModel
readonly IOperatingSystem os;
readonly IConnectionManager connectionManager;
readonly IRepositoryCloneService service;
readonly IGitService gitService;
readonly IUsageService usageService;
readonly IUsageTracker usageTracker;
readonly IReadOnlyList<IRepositoryCloneTabViewModel> tabs;
string path;
IRepositoryModel previousRepository;
ObservableAsPropertyHelper<string> pathError;
ObservableAsPropertyHelper<string> pathWarning;
int selectedTabIndex;

[ImportingConstructor]
public RepositoryCloneViewModel(
IOperatingSystem os,
IConnectionManager connectionManager,
IRepositoryCloneService service,
IGitService gitService,
IUsageService usageService,
IUsageTracker usageTracker,
IRepositorySelectViewModel gitHubTab,
Expand All @@ -45,6 +47,7 @@ public RepositoryCloneViewModel(
this.os = os;
this.connectionManager = connectionManager;
this.service = service;
this.gitService = gitService;
this.usageService = usageService;
this.usageTracker = usageTracker;

Expand All @@ -59,22 +62,27 @@ public RepositoryCloneViewModel(
Path = service.DefaultClonePath;
repository.Subscribe(x => UpdatePath(x));

pathError = Observable.CombineLatest(
pathWarning = Observable.CombineLatest(
repository,
this.WhenAnyValue(x => x.Path),
ValidatePath)
.ToProperty(this, x => x.PathError);
ValidatePathWarning)
.ToProperty(this, x => x.PathWarning);

var canClone = Observable.CombineLatest(
repository,
this.WhenAnyValue(x => x.PathError),
(repo, error) => (repo, error))
.Select(x => x.repo != null && x.error == null);
repository, this.WhenAnyValue(x => x.Path),
(repo, path) => repo != null && !service.DestinationFileExists(path) && !service.DestinationDirectoryExists(path));

var canOpen = Observable.CombineLatest(
repository, this.WhenAnyValue(x => x.Path),
(repo, path) => repo != null && !service.DestinationFileExists(path) && service.DestinationDirectoryExists(path));

Browse = ReactiveCommand.Create().OnExecuteCompleted(_ => BrowseForDirectory());
Clone = ReactiveCommand.CreateAsyncObservable(
canClone,
_ => repository.Select(x => new CloneDialogResult(Path, x)));
_ => repository.Select(x => new CloneDialogResult(Path, x?.CloneUrl)));
Open = ReactiveCommand.CreateAsyncObservable(
canOpen,
_ => repository.Select(x => new CloneDialogResult(Path, x?.CloneUrl)));
}

public IRepositorySelectViewModel GitHubTab { get; }
Expand All @@ -87,22 +95,24 @@ public string Path
set => this.RaiseAndSetIfChanged(ref path, value);
}

public string PathError => pathError.Value;
public string PathWarning => pathWarning.Value;

public int SelectedTabIndex
{
get => selectedTabIndex;
set => this.RaiseAndSetIfChanged(ref selectedTabIndex, value);
}

public string Title => Resources.CloneTitle;
public string Title => Resources.OpenFromGitHubTitle;

public IObservable<object> Done => Clone;
public IObservable<object> Done => Observable.Merge(Clone, Open);

public ReactiveCommand<object> Browse { get; }

public ReactiveCommand<CloneDialogResult> Clone { get; }

public ReactiveCommand<CloneDialogResult> Open { get; }

public async Task InitializeAsync(IConnection connection)
{
var connections = await connectionManager.GetLoadedConnections().ConfigureAwait(false);
Expand Down Expand Up @@ -228,13 +238,39 @@ string FindDirWithout(string dir, string match, int levels)
}
}

string ValidatePath(IRepositoryModel repository, string path)
string ValidatePathWarning(IRepositoryModel repositoryModel, string path)
{
if (repository != null)
if (repositoryModel != null)
{
return service.DestinationExists(path) ?
Resources.DestinationAlreadyExists :
null;
if (service.DestinationFileExists(path))
{
return Resources.DestinationAlreadyExists;
}

if (service.DestinationDirectoryExists(path))
{
using (var repository = gitService.GetRepository(path))
{
if (repository == null)
{
return Resources.CantFindARepositoryAtLocalPath;
}

var localUrl = gitService.GetRemoteUri(repository)?.ToRepositoryUrl();
if (localUrl == null)
{
return Resources.LocalRepositoryDoesntHaveARemoteOrigin;
}

var targetUrl = repositoryModel.CloneUrl?.ToRepositoryUrl();
if (localUrl != targetUrl)
{
return string.Format(CultureInfo.CurrentCulture, Resources.LocalRepositoryHasARemoteOf, localUrl);
}

return Resources.YouHaveAlreadyClonedToThisLocation;
}
}
}

return null;
Expand Down
29 changes: 26 additions & 3 deletions src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,36 @@ Task CloneRepository(
object progress = null);

/// <summary>
/// Checks whether the specified destination path already exists.
/// Clones the specified repository into the specified directory or opens it if the directory already exists.
/// </summary>
/// <param name="cloneDialogResult">The URL and path of the repository to clone or open.</param>
/// <param name="progress">
/// An object through which to report progress. This must be of type
/// System.IProgress&lt;Microsoft.VisualStudio.Shell.ServiceProgressData&gt;, but
/// as that type is only available in VS2017+ it is typed as <see cref="object"/> here.
/// </param>
/// <returns></returns>
Task CloneOrOpenRepository(
CloneDialogResult cloneDialogResult,
object progress = null);

/// <summary>
/// Checks whether the specified destination directory already exists.
/// </summary>
/// <param name="path">The destination path.</param>
/// <returns>
/// true if a file or directory is already present at <paramref name="path"/>; otherwise false.
/// true if a directory is already present at <paramref name="path"/>; otherwise false.
/// </returns>
bool DestinationDirectoryExists(string path);

/// <summary>
/// Checks whether the specified destination file already exists.
/// </summary>
/// <param name="path">The destination file.</param>
/// <returns>
/// true if a file is already present at <paramref name="path"/>; otherwise false.
/// </returns>
bool DestinationExists(string path);
bool DestinationFileExists(string path);

Task<ViewerRepositoriesModel> ReadViewerRepositories(HostAddress address);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace GitHub.ViewModels.Dialog.Clone
{
/// <summary>
/// ViewModel for the the Clone Repository dialog
/// ViewModel for the Clone Repository dialog
/// </summary>
public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel
{
Expand All @@ -30,9 +30,9 @@ public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectio
string Path { get; set; }

/// <summary>
/// Gets an error message that explains why <see cref="Path"/> is not valid.
/// Gets a warning message that explains why <see cref="Path"/> is suspect.
/// </summary>
string PathError { get; }
string PathWarning { get; }

/// <summary>
/// Gets the index of the selected tab.
Expand Down
9 changes: 5 additions & 4 deletions src/GitHub.Exports/Models/CloneDialogResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using GitHub.Primitives;

namespace GitHub.Models
{
Expand All @@ -12,10 +13,10 @@ public class CloneDialogResult
/// </summary>
/// <param name="path">The path to clone the repository to.</param>
/// <param name="repository">The selected repository.</param>
public CloneDialogResult(string path, IRepositoryModel repository)
public CloneDialogResult(string path, UriString cloneUrl)
{
Path = path;
Repository = repository;
Url = cloneUrl;
}

/// <summary>
Expand All @@ -24,8 +25,8 @@ public CloneDialogResult(string path, IRepositoryModel repository)
public string Path { get; }

/// <summary>
/// Gets the repository selected by the user.
/// Gets the url selected by the user.
/// </summary>
public IRepositoryModel Repository { get; }
public UriString Url { get; }
}
}
Loading