Merge pull request #1787 from github/refactor/clone

Refactored Clone Dialog
This commit is contained in:
Steven Kirk 2018-09-11 12:52:18 +02:00 коммит произвёл GitHub
Родитель 4eef5b3317 9784ce6e7e
Коммит 0e64838b84
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
72 изменённых файлов: 1962 добавлений и 1784 удалений

Просмотреть файл

@ -33,9 +33,14 @@ namespace GitHub.Api
public static string ClientSecret { get; private set; }
/// <summary>
/// Gets the scopes required by the application.
/// Gets the minimum scopes required by the application.
/// </summary>
public static IReadOnlyList<string> RequiredScopes { get; } = new[] { "user", "repo", "gist", "write:public_key" };
public static IReadOnlyList<string> MinimumScopes { get; } = new[] { "user", "repo", "gist", "write:public_key" };
/// <summary>
/// Gets the ideal scopes requested by the application.
/// </summary>
public static IReadOnlyList<string> RequestedScopes { get; } = new[] { "user", "repo", "gist", "write:public_key", "read:org" };
/// <summary>
/// Gets a note that will be stored with the OAUTH token.

Просмотреть файл

@ -21,11 +21,11 @@ namespace GitHub.Api
/// <param name="client">An octokit client configured to access the server.</param>
/// <param name="userName">The username.</param>
/// <param name="password">The password.</param>
/// <returns>The logged in user.</returns>
/// <returns>A <see cref="LoginResult"/> with the details of the successful login.</returns>
/// <exception cref="AuthorizationException">
/// The login authorization failed.
/// </exception>
Task<User> Login(HostAddress hostAddress, IGitHubClient client, string userName, string password);
Task<LoginResult> Login(HostAddress hostAddress, IGitHubClient client, string userName, string password);
/// <summary>
/// Attempts to log into a GitHub server via OAuth in the browser.
@ -35,11 +35,11 @@ namespace GitHub.Api
/// <param name="oauthClient">An octokit OAuth client configured to access the server.</param>
/// <param name="openBrowser">A callback that should open a browser at the requested URL.</param>
/// <param name="cancel">A cancellation token used to cancel the operation.</param>
/// <returns>The logged in user.</returns>
/// <returns>A <see cref="LoginResult"/> with the details of the successful login.</returns>
/// <exception cref="AuthorizationException">
/// The login authorization failed.
/// </exception>
Task<User> LoginViaOAuth(
Task<LoginResult> LoginViaOAuth(
HostAddress hostAddress,
IGitHubClient client,
IOauthClient oauthClient,
@ -52,7 +52,8 @@ namespace GitHub.Api
/// <param name="hostAddress">The address of the server.</param>
/// <param name="client">An octokit client configured to access the server.</param>
/// <param name="token">The token.</param>
Task<User> LoginWithToken(
/// <returns>A <see cref="LoginResult"/> with the details of the successful login.</returns>
Task<LoginResult> LoginWithToken(
HostAddress hostAddress,
IGitHubClient client,
string token);
@ -62,11 +63,11 @@ namespace GitHub.Api
/// </summary>
/// <param name="hostAddress">The address of the server.</param>
/// <param name="client">An octokit client configured to access the server.</param>
/// <returns>The logged in user.</returns>
/// <returns>A <see cref="LoginResult"/> with the details of the successful login.</returns>
/// <exception cref="AuthorizationException">
/// The login authorization failed.
/// </exception>
Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client);
Task<LoginResult> LoginFromCache(HostAddress hostAddress, IGitHubClient client);
/// <summary>
/// Logs out of GitHub server.

Просмотреть файл

@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Logging;
using GitHub.Models;
using GitHub.Primitives;
using Octokit;
using Serilog;
@ -24,7 +25,8 @@ namespace GitHub.Api
readonly Lazy<ITwoFactorChallengeHandler> twoFactorChallengeHandler;
readonly string clientId;
readonly string clientSecret;
readonly IReadOnlyList<string> scopes;
readonly IReadOnlyList<string> minimumScopes;
readonly IReadOnlyList<string> requestedScopes;
readonly string authorizationNote;
readonly string fingerprint;
IOAuthCallbackListener oauthListener;
@ -37,7 +39,8 @@ namespace GitHub.Api
/// <param name="oauthListener">The callback listener to signal successful login.</param>
/// <param name="clientId">The application's client API ID.</param>
/// <param name="clientSecret">The application's client API secret.</param>
/// <param name="scopes">List of scopes to authenticate for</param>
/// <param name="minimumScopes">The minimum acceptable scopes.</param>
/// <param name="requestedScopes">The scopes to request when logging in.</param>
/// <param name="authorizationNote">An note to store with the authorization.</param>
/// <param name="fingerprint">The machine fingerprint.</param>
public LoginManager(
@ -46,7 +49,8 @@ namespace GitHub.Api
IOAuthCallbackListener oauthListener,
string clientId,
string clientSecret,
IReadOnlyList<string> scopes,
IReadOnlyList<string> minimumScopes,
IReadOnlyList<string> requestedScopes,
string authorizationNote = null,
string fingerprint = null)
{
@ -60,13 +64,14 @@ namespace GitHub.Api
this.oauthListener = oauthListener;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scopes = scopes;
this.minimumScopes = minimumScopes;
this.requestedScopes = requestedScopes;
this.authorizationNote = authorizationNote;
this.fingerprint = fingerprint;
}
/// <inheritdoc/>
public async Task<User> Login(
public async Task<LoginResult> Login(
HostAddress hostAddress,
IGitHubClient client,
string userName,
@ -83,7 +88,7 @@ namespace GitHub.Api
var newAuth = new NewAuthorization
{
Scopes = scopes,
Scopes = requestedScopes,
Note = authorizationNote,
Fingerprint = fingerprint,
};
@ -121,11 +126,11 @@ namespace GitHub.Api
} while (auth == null);
await keychain.Save(userName, auth.Token, hostAddress).ConfigureAwait(false);
return await ReadUserWithRetry(client);
return await ReadUserWithRetry(client).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<User> LoginViaOAuth(
public async Task<LoginResult> LoginViaOAuth(
HostAddress hostAddress,
IGitHubClient client,
IOauthClient oauthClient,
@ -143,18 +148,18 @@ namespace GitHub.Api
openBrowser(loginUrl);
var code = await listen;
var code = await listen.ConfigureAwait(false);
var request = new OauthTokenRequest(clientId, clientSecret, code);
var token = await oauthClient.CreateAccessToken(request);
var token = await oauthClient.CreateAccessToken(request).ConfigureAwait(false);
await keychain.Save("[oauth]", token.AccessToken, hostAddress).ConfigureAwait(false);
var user = await ReadUserWithRetry(client);
await keychain.Save(user.Login, token.AccessToken, hostAddress).ConfigureAwait(false);
return user;
var result = await ReadUserWithRetry(client).ConfigureAwait(false);
await keychain.Save(result.User.Login, token.AccessToken, hostAddress).ConfigureAwait(false);
return result;
}
/// <inheritdoc/>
public async Task<User> LoginWithToken(
public async Task<LoginResult> LoginWithToken(
HostAddress hostAddress,
IGitHubClient client,
string token)
@ -167,19 +172,19 @@ namespace GitHub.Api
try
{
var user = await ReadUserWithRetry(client);
await keychain.Save(user.Login, token, hostAddress).ConfigureAwait(false);
return user;
var result = await ReadUserWithRetry(client).ConfigureAwait(false);
await keychain.Save(result.User.Login, token, hostAddress).ConfigureAwait(false);
return result;
}
catch
{
await keychain.Delete(hostAddress);
await keychain.Delete(hostAddress).ConfigureAwait(false);
throw;
}
}
/// <inheritdoc/>
public Task<User> LoginFromCache(HostAddress hostAddress, IGitHubClient client)
public Task<LoginResult> LoginFromCache(HostAddress hostAddress, IGitHubClient client)
{
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
Guard.ArgumentNotNull(client, nameof(client));
@ -193,41 +198,7 @@ namespace GitHub.Api
Guard.ArgumentNotNull(hostAddress, nameof(hostAddress));
Guard.ArgumentNotNull(client, nameof(client));
await keychain.Delete(hostAddress);
}
/// <summary>
/// Tests if received API scopes match the required API scopes.
/// </summary>
/// <param name="required">The required API scopes.</param>
/// <param name="received">The received API scopes.</param>
/// <returns>True if all required scopes are present, otherwise false.</returns>
public static bool ScopesMatch(IReadOnlyList<string> required, IReadOnlyList<string> received)
{
foreach (var scope in required)
{
var found = received.Contains(scope);
if (!found &&
(scope.StartsWith("read:", StringComparison.Ordinal) ||
scope.StartsWith("write:", StringComparison.Ordinal)))
{
// NOTE: Scopes are actually more complex than this, for example
// `user` encompasses `read:user` and `user:email` but just use
// this simple rule for now as it works for the scopes we require.
var adminScope = scope
.Replace("read:", "admin:")
.Replace("write:", "admin:");
found = received.Contains(adminScope);
}
if (!found)
{
return false;
}
}
return true;
await keychain.Delete(hostAddress).ConfigureAwait(false);
}
async Task<ApplicationAuthorization> CreateAndDeleteExistingApplicationAuthorization(
@ -256,18 +227,18 @@ namespace GitHub.Api
twoFactorAuthenticationCode).ConfigureAwait(false);
}
if (result.Token == string.Empty)
if (string.IsNullOrEmpty(result.Token))
{
if (twoFactorAuthenticationCode == null)
{
await client.Authorization.Delete(result.Id);
await client.Authorization.Delete(result.Id).ConfigureAwait(false);
}
else
{
await client.Authorization.Delete(result.Id, twoFactorAuthenticationCode);
await client.Authorization.Delete(result.Id, twoFactorAuthenticationCode).ConfigureAwait(false);
}
}
} while (result.Token == string.Empty && retry++ == 0);
} while (string.IsNullOrEmpty(result.Token) && retry++ == 0);
return result;
}
@ -280,7 +251,7 @@ namespace GitHub.Api
{
for (;;)
{
var challengeResult = await twoFactorChallengeHandler.Value.HandleTwoFactorException(exception);
var challengeResult = await twoFactorChallengeHandler.Value.HandleTwoFactorException(exception).ConfigureAwait(false);
if (challengeResult == null)
{
@ -304,7 +275,7 @@ namespace GitHub.Api
}
catch (Exception e)
{
await twoFactorChallengeHandler.Value.ChallengeFailed(e);
await twoFactorChallengeHandler.Value.ChallengeFailed(e).ConfigureAwait(false);
await keychain.Delete(hostAddress).ConfigureAwait(false);
throw;
}
@ -345,7 +316,7 @@ namespace GitHub.Api
apiException?.StatusCode == (HttpStatusCode)422);
}
async Task<User> ReadUserWithRetry(IGitHubClient client)
async Task<LoginResult> ReadUserWithRetry(IGitHubClient client)
{
var retry = 0;
@ -362,29 +333,29 @@ namespace GitHub.Api
// It seems that attempting to use a token immediately sometimes fails, retry a few
// times with a delay of of 1s to allow the token to propagate.
await Task.Delay(1000);
await Task.Delay(1000).ConfigureAwait(false);
}
}
async Task<User> GetUserAndCheckScopes(IGitHubClient client)
async Task<LoginResult> GetUserAndCheckScopes(IGitHubClient client)
{
var response = await client.Connection.Get<User>(
UserEndpoint, null, null).ConfigureAwait(false);
if (response.HttpResponse.Headers.ContainsKey(ScopesHeader))
{
var returnedScopes = response.HttpResponse.Headers[ScopesHeader]
var returnedScopes = new ScopesCollection(response.HttpResponse.Headers[ScopesHeader]
.Split(',')
.Select(x => x.Trim())
.ToArray();
.ToArray());
if (ScopesMatch(scopes, returnedScopes))
if (returnedScopes.Matches(minimumScopes))
{
return response.Body;
return new LoginResult(response.Body, returnedScopes);
}
else
{
log.Error("Incorrect API scopes: require {RequiredScopes} but got {Scopes}", scopes, returnedScopes);
log.Error("Incorrect API scopes: require {RequiredScopes} but got {Scopes}", minimumScopes, returnedScopes);
}
}
else
@ -393,7 +364,7 @@ namespace GitHub.Api
}
throw new IncorrectScopesException(
"Incorrect API scopes. Required: " + string.Join(",", scopes));
"Incorrect API scopes. Required: " + string.Join(",", minimumScopes));
}
Uri GetLoginUrl(IOauthClient client, string state)
@ -402,7 +373,7 @@ namespace GitHub.Api
request.State = state;
foreach (var scope in scopes)
foreach (var scope in requestedScopes)
{
request.Scopes.Add(scope);
}

Просмотреть файл

@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using GitHub.Models;
using Octokit;
namespace GitHub.Api
{
/// <summary>
/// Holds the result of a successful login by <see cref="ILoginManager"/>.
/// </summary>
public class LoginResult
{
/// <summary>
/// Initializes a new instance of the <see cref="LoginResult"/> class.
/// </summary>
/// <param name="user">The logged-in user.</param>
/// <param name="scopes">The login scopes.</param>
public LoginResult(User user, ScopesCollection scopes)
{
User = user;
Scopes = scopes;
}
/// <summary>
/// Gets the login scopes.
/// </summary>
public ScopesCollection Scopes { get; }
/// <summary>
/// Gets the logged-in user.
/// </summary>
public User User { get; }
}
}

Просмотреть файл

@ -1,6 +1,8 @@
using System.Windows.Markup;
[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData")]
[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData.Dialog.Clone")]
[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels")]
[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog")]
[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog.Clone")]
[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.GitHubPane")]

20
src/GitHub.App/Resources.Designer.cs сгенерированный
Просмотреть файл

@ -115,7 +115,7 @@ namespace GitHub.App {
}
/// <summary>
/// Looks up a localized string similar to Clone a {0} Repository.
/// Looks up a localized string similar to Clone a Repository.
/// </summary>
public static string CloneTitle {
get {
@ -180,6 +180,15 @@ namespace GitHub.App {
}
}
/// <summary>
/// Looks up a localized string similar to The destination already exists..
/// </summary>
public static string DestinationAlreadyExists {
get {
return ResourceManager.GetString("DestinationAlreadyExists", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please enter an Enterprise URL.
/// </summary>
@ -288,6 +297,15 @@ namespace GitHub.App {
}
}
/// <summary>
/// Looks up a localized string similar to Logout Required.
/// </summary>
public static string LogoutRequired {
get {
return ResourceManager.GetString("LogoutRequired", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You must pull before you can push.
/// </summary>

Просмотреть файл

@ -121,7 +121,7 @@
<value>Select a containing folder for your new repository.</value>
</data>
<data name="CloneTitle" xml:space="preserve">
<value>Clone a {0} Repository</value>
<value>Clone a Repository</value>
</data>
<data name="CouldNotConnectToGitHub" xml:space="preserve">
<value>Could not connect to github.com</value>
@ -327,4 +327,10 @@ https://git-scm.com/download/win</value>
<data name="CancelPendingReviewConfirmationCaption" xml:space="preserve">
<value>Cancel Review</value>
</data>
<data name="DestinationAlreadyExists" xml:space="preserve">
<value>The destination already exists.</value>
</data>
<data name="LogoutRequired" xml:space="preserve">
<value>Logout Required</value>
</data>
</root>

Просмотреть файл

@ -0,0 +1,34 @@
using System;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog.Clone;
using ReactiveUI;
namespace GitHub.SampleData.Dialog.Clone
{
public class RepositoryCloneViewModelDesigner : ViewModelBase, IRepositoryCloneViewModel
{
public RepositoryCloneViewModelDesigner()
{
GitHubTab = new SelectPageViewModelDesigner();
EnterpriseTab = new SelectPageViewModelDesigner();
}
public string Path { get; set; }
public string PathError { get; set; }
public int SelectedTabIndex { get; set; }
public string Title => null;
public IObservable<object> Done => null;
public IRepositorySelectViewModel GitHubTab { get; }
public IRepositorySelectViewModel EnterpriseTab { get; }
public IRepositoryUrlViewModel UrlTab { get; }
public ReactiveCommand<object> Browse { get; }
public ReactiveCommand<CloneDialogResult> Clone { get; }
public Task InitializeAsync(IConnection connection)
{
throw new NotImplementedException();
}
}
}

Просмотреть файл

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Data;
using GitHub.Models;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog.Clone;
namespace GitHub.SampleData.Dialog.Clone
{
public class SelectPageViewModelDesigner : ViewModelBase, IRepositorySelectViewModel
{
public SelectPageViewModelDesigner()
{
var items = new[]
{
new RepositoryListItemModel { Name = "encourage", Owner = "haacked" },
new RepositoryListItemModel { Name = "haacked.com", Owner = "haacked", IsFork = true },
new RepositoryListItemModel { Name = "octokit.net", Owner = "octokit" },
new RepositoryListItemModel { Name = "octokit.rb", Owner = "octokit" },
new RepositoryListItemModel { Name = "octokit.objc", Owner = "octokit" },
new RepositoryListItemModel { Name = "windows", Owner = "github" },
new RepositoryListItemModel { Name = "mac", Owner = "github", IsPrivate = true },
new RepositoryListItemModel { Name = "github", Owner = "github", IsPrivate = true }
};
Items = items.Select(x => new RepositoryItemViewModel(x, x.Owner)).ToList();
ItemsView = CollectionViewSource.GetDefaultView(Items);
ItemsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RepositoryItemViewModel.Group)));
}
public Exception Error { get; set; }
public string Filter { get; set; }
public bool IsEnabled { get; set; } = true;
public bool IsLoading { get; set; }
public IReadOnlyList<IRepositoryItemViewModel> Items { get; }
public ICollectionView ItemsView { get; }
public IRepositoryItemViewModel SelectedItem { get; set; }
public IRepositoryModel Repository { get; }
public void Initialize(IConnection connection)
{
}
public Task Activate()
{
return Task.CompletedTask;
}
}
}

Просмотреть файл

@ -13,6 +13,7 @@ using GitHub.UI;
using GitHub.Validation;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog;
using GitHub.ViewModels.Dialog.Clone;
using GitHub.ViewModels.TeamExplorer;
using GitHub.VisualStudio.TeamExplorer.Connect;
using GitHub.VisualStudio.TeamExplorer.Home;
@ -198,9 +199,8 @@ namespace GitHub.SampleData
public HostAddress HostAddress { get; set; }
public string Username { get; set; }
public ObservableCollection<ILocalRepositoryModel> Repositories { get; set; }
public Octokit.User User => null;
public ScopesCollection Scopes => null;
public bool IsLoggedIn => true;
public bool IsLoggingIn => false;
@ -261,195 +261,4 @@ namespace GitHub.SampleData
return new RemoteRepositoryModel(0, name, new UriString("http://github.com/" + name + "/" + owner), false, false, new AccountDesigner() { Login = owner }, null);
}
}
public class RepositoryCloneViewModelDesigner : ViewModelBase, IRepositoryCloneViewModel
{
public RepositoryCloneViewModelDesigner()
{
Repositories = new ObservableCollection<IRemoteRepositoryModel>
{
RepositoryModelDesigner.Create("encourage", "haacked"),
RepositoryModelDesigner.Create("haacked.com", "haacked"),
RepositoryModelDesigner.Create("octokit.net", "octokit"),
RepositoryModelDesigner.Create("octokit.rb", "octokit"),
RepositoryModelDesigner.Create("octokit.objc", "octokit"),
RepositoryModelDesigner.Create("windows", "github"),
RepositoryModelDesigner.Create("mac", "github"),
RepositoryModelDesigner.Create("github", "github")
};
BrowseForDirectory = ReactiveCommand.Create();
BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(this.WhenAny(x => x.BaseRepositoryPath, x => x.Value))
.IfNullOrEmpty("Please enter a repository path")
.IfTrue(x => x.Length > 200, "Path too long")
.IfContainsInvalidPathChars("Path contains invalid characters")
.IfPathNotRooted("Please enter a valid path");
}
public IReactiveCommand<object> CloneCommand
{
get;
private set;
}
public IRepositoryModel SelectedRepository { get; set; }
public ObservableCollection<IRemoteRepositoryModel> Repositories
{
get;
private set;
}
public bool FilterTextIsEnabled
{
get;
private set;
}
public string FilterText { get; set; }
public string Title { get { return "Clone a GitHub Repository"; } }
public IReactiveCommand<IReadOnlyList<IRemoteRepositoryModel>> LoadRepositoriesCommand
{
get;
private set;
}
public bool LoadingFailed
{
get { return false; }
}
public bool NoRepositoriesFound
{
get;
set;
}
public ICommand BrowseForDirectory
{
get;
private set;
}
public string BaseRepositoryPath
{
get;
set;
}
public bool CanClone
{
get;
private set;
}
public ReactivePropertyValidator<string> BaseRepositoryPathValidator
{
get;
private set;
}
public IObservable<object> Done { get; }
public Task InitializeAsync(IConnection connection) => Task.CompletedTask;
}
public class GitHubHomeSectionDesigner : IGitHubHomeSection
{
public GitHubHomeSectionDesigner()
{
Icon = Octicon.repo;
RepoName = "octokit";
RepoUrl = "https://github.com/octokit/something-really-long-here-to-check-for-trimming";
IsLoggedIn = false;
}
public Octicon Icon
{
get;
private set;
}
public bool IsLoggedIn
{
get;
private set;
}
public string RepoName
{
get;
set;
}
public string RepoUrl
{
get;
set;
}
public void Login()
{
}
public ICommand OpenOnGitHub { get; }
}
public class GitHubConnectSectionDesigner : IGitHubConnectSection
{
public GitHubConnectSectionDesigner()
{
Repositories = new ObservableCollection<ILocalRepositoryModel>();
Repositories.Add(new LocalRepositoryModel("octokit", new UriString("https://github.com/octokit/octokit.net"), @"C:\Users\user\Source\Repos\octokit.net", new GitServiceDesigner()));
Repositories.Add(new LocalRepositoryModel("cefsharp", new UriString("https://github.com/cefsharp/cefsharp"), @"C:\Users\user\Source\Repos\cefsharp", new GitServiceDesigner()));
Repositories.Add(new LocalRepositoryModel("git-lfs", new UriString("https://github.com/github/git-lfs"), @"C:\Users\user\Source\Repos\git-lfs", new GitServiceDesigner()));
Repositories.Add(new LocalRepositoryModel("another octokit", new UriString("https://github.com/octokit/octokit.net"), @"C:\Users\user\Source\Repos\another-octokit.net", new GitServiceDesigner()));
Repositories.Add(new LocalRepositoryModel("some cefsharp", new UriString("https://github.com/cefsharp/cefsharp"), @"C:\Users\user\Source\Repos\something-else", new GitServiceDesigner()));
Repositories.Add(new LocalRepositoryModel("even more git-lfs", new UriString("https://github.com/github/git-lfs"), @"C:\Users\user\Source\Repos\A different path", new GitServiceDesigner()));
}
public ObservableCollection<ILocalRepositoryModel> Repositories
{
get; set;
}
public void DoCreate()
{
}
public void SignOut()
{
}
public void Login()
{
}
public void Retry()
{
}
public bool OpenRepository()
{
return true;
}
public string ErrorMessage { get; set; }
public IConnection SectionConnection { get; }
public bool IsLoggingIn { get; set; }
public bool ShowLogin { get; set; }
public bool ShowLogout { get; set; }
public bool ShowRetry { get; set; }
public ICommand Clone { get; }
}
public class InfoPanelDesigner
{
public string Message => "This is an informational message for the [info panel](link) to test things in design mode.";
public MessageType MessageType => MessageType.Information;
}
}

Просмотреть файл

@ -1,10 +1,12 @@
using System;
using System.ComponentModel.Composition;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Extensions;
using GitHub.Factories;
using GitHub.Models;
using GitHub.ViewModels.Dialog;
using GitHub.ViewModels.Dialog.Clone;
namespace GitHub.Services
{
@ -29,17 +31,15 @@ namespace GitHub.Services
public async Task<CloneDialogResult> ShowCloneDialog(IConnection connection)
{
Guard.ArgumentNotNull(connection, nameof(connection));
var viewModel = factory.CreateViewModel<IRepositoryCloneViewModel>();
if (connection != null)
{
await viewModel.InitializeAsync(connection);
return (CloneDialogResult)await showDialog.Show(viewModel);
}
else
{
return (CloneDialogResult)await showDialog.ShowWithFirstConnection(viewModel);
}
return (CloneDialogResult)await showDialog.Show(
viewModel,
connection,
ApiClientConfiguration.RequestedScopes)
.ConfigureAwait(false);
}
public async Task<string> ShowReCloneDialog(IRepositoryModel repository)

Просмотреть файл

@ -1,14 +1,21 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.IO;
using System.Reactive;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Extensions;
using GitHub.Logging;
using Microsoft.VisualStudio.Shell;
using Serilog;
using Rothko;
using GitHub.Helpers;
using GitHub.Logging;
using GitHub.Models;
using GitHub.Primitives;
using Microsoft.VisualStudio.Shell;
using Octokit.GraphQL;
using Octokit.GraphQL.Model;
using Rothko;
using Serilog;
using Task = System.Threading.Tasks.Task;
namespace GitHub.Services
@ -27,21 +34,74 @@ namespace GitHub.Services
readonly IOperatingSystem operatingSystem;
readonly string defaultClonePath;
readonly IVSGitServices vsGitServices;
readonly IGraphQLClientFactory graphqlFactory;
readonly IUsageTracker usageTracker;
ICompiledQuery<ViewerRepositoriesModel> readViewerRepositories;
[ImportingConstructor]
public RepositoryCloneService(
IOperatingSystem operatingSystem,
IVSGitServices vsGitServices,
IGraphQLClientFactory graphqlFactory,
IUsageTracker usageTracker)
{
this.operatingSystem = operatingSystem;
this.vsGitServices = vsGitServices;
this.graphqlFactory = graphqlFactory;
this.usageTracker = usageTracker;
defaultClonePath = GetLocalClonePathFromGitProvider(operatingSystem.Environment.GetUserRepositoriesPath());
}
/// <inheritdoc/>
public async Task<ViewerRepositoriesModel> ReadViewerRepositories(HostAddress address)
{
if (readViewerRepositories == null)
{
var order = new RepositoryOrder
{
Field = RepositoryOrderField.Name,
Direction = OrderDirection.Asc
};
var affiliation = new RepositoryAffiliation?[]
{
RepositoryAffiliation.Owner
};
var repositorySelection = new Fragment<Repository, RepositoryListItemModel>(
"repository",
repo => new RepositoryListItemModel
{
IsFork = repo.IsFork,
IsPrivate = repo.IsPrivate,
Name = repo.Name,
Owner = repo.Owner.Login,
Url = new Uri(repo.Url),
});
readViewerRepositories = new Query()
.Viewer
.Select(viewer => new ViewerRepositoriesModel
{
Repositories = viewer.Repositories(null, null, null, null, null, order, affiliation, null, null)
.AllPages()
.Select(repositorySelection).ToList(),
OrganizationRepositories = viewer.Organizations(null, null, null, null).AllPages().Select(org => new
{
org.Login,
Repositories = org.Repositories(null, null, null, null, null, order, null, null, null)
.AllPages()
.Select(repositorySelection).ToList()
}).ToDictionary(x => x.Login, x => (IReadOnlyList<RepositoryListItemModel>)x.Repositories),
}).Compile();
}
var graphql = await graphqlFactory.CreateConnection(address).ConfigureAwait(false);
var result = await graphql.Run(readViewerRepositories).ConfigureAwait(false);
return result;
}
/// <inheritdoc/>
public async Task CloneRepository(
string cloneUrl,
@ -73,6 +133,9 @@ namespace GitHub.Services
}
}
/// <inheritdoc/>
public bool DestinationExists(string path) => Directory.Exists(path) || File.Exists(path);
string GetLocalClonePathFromGitProvider(string fallbackPath)
{
var ret = vsGitServices.GetLocalClonePathFromGitProvider();
@ -82,5 +145,10 @@ namespace GitHub.Services
}
public string DefaultClonePath { get { return defaultClonePath; } }
class OrganizationAdapter
{
public IReadOnlyList<RepositoryListItemModel> Repositories { get; set; }
}
}
}

Просмотреть файл

@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using GitHub.App;
using GitHub.Extensions;
using GitHub.Logging;
using GitHub.Models;
using GitHub.Services;
using ReactiveUI;
using Rothko;
using Serilog;
namespace GitHub.ViewModels.Dialog.Clone
{
[Export(typeof(IRepositoryCloneViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class RepositoryCloneViewModel : ViewModelBase, IRepositoryCloneViewModel
{
static readonly ILogger log = LogManager.ForContext<RepositoryCloneViewModel>();
readonly IOperatingSystem os;
readonly IConnectionManager connectionManager;
readonly IRepositoryCloneService service;
readonly IReadOnlyList<IRepositoryCloneTabViewModel> tabs;
string path;
ObservableAsPropertyHelper<string> pathError;
int selectedTabIndex;
[ImportingConstructor]
public RepositoryCloneViewModel(
IOperatingSystem os,
IConnectionManager connectionManager,
IRepositoryCloneService service,
IRepositorySelectViewModel gitHubTab,
IRepositorySelectViewModel enterpriseTab,
IRepositoryUrlViewModel urlTab)
{
this.os = os;
this.connectionManager = connectionManager;
this.service = service;
GitHubTab = gitHubTab;
EnterpriseTab = enterpriseTab;
UrlTab = urlTab;
tabs = new IRepositoryCloneTabViewModel[] { GitHubTab, EnterpriseTab, UrlTab };
var repository = this.WhenAnyValue(x => x.SelectedTabIndex)
.SelectMany(x => tabs[x].WhenAnyValue(tab => tab.Repository));
Path = service.DefaultClonePath;
repository.Subscribe(x => UpdatePath(x));
pathError = Observable.CombineLatest(
repository,
this.WhenAnyValue(x => x.Path),
ValidatePath)
.ToProperty(this, x => x.PathError);
var canClone = Observable.CombineLatest(
repository,
this.WhenAnyValue(x => x.PathError),
(repo, error) => (repo, error))
.Select(x => x.repo != null && x.error == null);
Browse = ReactiveCommand.Create().OnExecuteCompleted(_ => BrowseForDirectory());
Clone = ReactiveCommand.CreateAsyncObservable(
canClone,
_ => repository.Select(x => new CloneDialogResult(Path, x)));
}
public IRepositorySelectViewModel GitHubTab { get; }
public IRepositorySelectViewModel EnterpriseTab { get; }
public IRepositoryUrlViewModel UrlTab { get; }
public string Path
{
get => path;
set => this.RaiseAndSetIfChanged(ref path, value);
}
public string PathError => pathError.Value;
public int SelectedTabIndex
{
get => selectedTabIndex;
set => this.RaiseAndSetIfChanged(ref selectedTabIndex, value);
}
public string Title => Resources.CloneTitle;
public IObservable<object> Done => Clone;
public ReactiveCommand<object> Browse { get; }
public ReactiveCommand<CloneDialogResult> Clone { get; }
public async Task InitializeAsync(IConnection connection)
{
var connections = await connectionManager.GetLoadedConnections().ConfigureAwait(false);
var gitHubConnection = connections.FirstOrDefault(x => x.HostAddress.IsGitHubDotCom());
var enterpriseConnection = connections.FirstOrDefault(x => !x.HostAddress.IsGitHubDotCom());
if (gitHubConnection?.IsLoggedIn == true)
{
GitHubTab.Initialize(gitHubConnection);
}
if (enterpriseConnection?.IsLoggedIn == true)
{
EnterpriseTab.Initialize(enterpriseConnection);
}
if (connection == enterpriseConnection)
{
SelectedTabIndex = 1;
}
this.WhenAnyValue(x => x.SelectedTabIndex).Subscribe(x => tabs[x].Activate().Forget());
}
void BrowseForDirectory()
{
var result = os.Dialog.BrowseForDirectory(Path, Resources.BrowseForDirectory);
if (result != BrowseDirectoryResult.Failed)
{
var path = result.DirectoryPath;
var selected = tabs[SelectedTabIndex].Repository;
if (selected != null)
{
path = System.IO.Path.Combine(path, selected.Name);
}
Path = path;
}
}
void UpdatePath(IRepositoryModel x)
{
if (x != null)
{
if (Path == service.DefaultClonePath)
{
Path = System.IO.Path.Combine(Path, x.Name);
}
else
{
var basePath = System.IO.Path.GetDirectoryName(Path);
Path = System.IO.Path.Combine(basePath, x.Name);
}
}
}
string ValidatePath(IRepositoryModel repository, string path)
{
if (repository != null)
{
return service.DestinationExists(path) ?
Resources.DestinationAlreadyExists :
null;
}
return null;
}
}
}

Просмотреть файл

@ -0,0 +1,29 @@
using System;
using GitHub.Models;
using GitHub.UI;
namespace GitHub.ViewModels.Dialog.Clone
{
public class RepositoryItemViewModel : ViewModelBase, IRepositoryItemViewModel
{
public RepositoryItemViewModel(RepositoryListItemModel model, string group)
{
Name = model.Name;
Owner = model.Owner;
Icon = model.IsPrivate
? Octicon.@lock
: model.IsFork
? Octicon.repo_forked
: Octicon.repo;
Url = model.Url;
Group = group;
}
public string Caption => Owner + '/' + Name;
public string Name { get; }
public string Owner { get; }
public string Group { get; }
public Octicon Icon { get; }
public Uri Url { get; }
}
}

Просмотреть файл

@ -0,0 +1,157 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.Composition;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using System.Windows.Data;
using GitHub.Extensions;
using GitHub.Logging;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.Services;
using ReactiveUI;
using Serilog;
namespace GitHub.ViewModels.Dialog.Clone
{
[Export(typeof(IRepositorySelectViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class RepositorySelectViewModel : ViewModelBase, IRepositorySelectViewModel
{
static readonly ILogger log = LogManager.ForContext<RepositorySelectViewModel>();
readonly IRepositoryCloneService service;
IConnection connection;
Exception error;
string filter;
bool isEnabled;
bool isLoading;
bool loadingStarted;
IReadOnlyList<IRepositoryItemViewModel> items;
ICollectionView itemsView;
ObservableAsPropertyHelper<IRepositoryModel> repository;
IRepositoryItemViewModel selectedItem;
[ImportingConstructor]
public RepositorySelectViewModel(IRepositoryCloneService service)
{
Guard.ArgumentNotNull(service, nameof(service));
this.service = service;
repository = this.WhenAnyValue(x => x.SelectedItem)
.Select(CreateRepository)
.ToProperty(this, x => x.Repository);
this.WhenAnyValue(x => x.Filter).Subscribe(_ => ItemsView?.Refresh());
}
public Exception Error
{
get => error;
private set => this.RaiseAndSetIfChanged(ref error, value);
}
public string Filter
{
get => filter;
set => this.RaiseAndSetIfChanged(ref filter, value);
}
public bool IsEnabled
{
get => isEnabled;
private set => this.RaiseAndSetIfChanged(ref isEnabled, value);
}
public bool IsLoading
{
get => isLoading;
private set => this.RaiseAndSetIfChanged(ref isLoading, value);
}
public IReadOnlyList<IRepositoryItemViewModel> Items
{
get => items;
private set => this.RaiseAndSetIfChanged(ref items, value);
}
public ICollectionView ItemsView
{
get => itemsView;
private set => this.RaiseAndSetIfChanged(ref itemsView, value);
}
public IRepositoryItemViewModel SelectedItem
{
get => selectedItem;
set => this.RaiseAndSetIfChanged(ref selectedItem, value);
}
public IRepositoryModel Repository => repository.Value;
public void Initialize(IConnection connection)
{
Guard.ArgumentNotNull(connection, nameof(connection));
this.connection = connection;
IsEnabled = true;
}
public async Task Activate()
{
if (connection == null || loadingStarted) return;
Error = null;
IsLoading = true;
loadingStarted = true;
try
{
var results = await service.ReadViewerRepositories(connection.HostAddress).ConfigureAwait(true);
var yourRepositories = results.Repositories
.Select(x => new RepositoryItemViewModel(x, "Your repositories"));
var orgRepositories = results.OrganizationRepositories
.OrderBy(x => x.Key)
.SelectMany(x => x.Value.Select(y => new RepositoryItemViewModel(y, x.Key)));
Items = yourRepositories.Concat(orgRepositories).ToList();
ItemsView = CollectionViewSource.GetDefaultView(Items);
ItemsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(RepositoryItemViewModel.Group)));
ItemsView.Filter = FilterItem;
}
catch (Exception ex)
{
log.Error(ex, "Error reading repository list from {Address}", connection.HostAddress);
if (ex is AggregateException aggregate)
{
ex = aggregate.InnerExceptions[0];
}
Error = ex;
}
finally
{
IsLoading = false;
}
}
bool FilterItem(object obj)
{
if (obj is IRepositoryItemViewModel item && !string.IsNullOrWhiteSpace(Filter))
{
return item.Caption.Contains(Filter, StringComparison.CurrentCultureIgnoreCase);
}
return true;
}
IRepositoryModel CreateRepository(IRepositoryItemViewModel item)
{
return item != null ?
new RepositoryModel(item.Name, UriString.ToUriString(item.Url)) :
null;
}
}
}

Просмотреть файл

@ -0,0 +1,65 @@
using System;
using System.ComponentModel.Composition;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog.Clone;
using ReactiveUI;
namespace GitHub.App.ViewModels.Dialog.Clone
{
[Export(typeof(IRepositoryUrlViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class RepositoryUrlViewModel : ViewModelBase, IRepositoryUrlViewModel
{
ObservableAsPropertyHelper<IRepositoryModel> repository;
string url;
public RepositoryUrlViewModel()
{
repository = this.WhenAnyValue(x => x.Url, ParseUrl).ToProperty(this, x => x.Repository);
}
public string Url
{
get => url;
set => this.RaiseAndSetIfChanged(ref url, value);
}
public bool IsEnabled => true;
public IRepositoryModel Repository => repository.Value;
public Task Activate() => Task.CompletedTask;
IRepositoryModel ParseUrl(string s)
{
if (s != null)
{
var uri = new UriString(s);
if (string.IsNullOrWhiteSpace(uri.Owner) || !string.IsNullOrWhiteSpace(uri.RepositoryName))
{
var parts = s.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2)
{
uri = UriString.ToUriString(
HostAddress.GitHubDotComHostAddress.WebUri
.Append(parts[0])
.Append(parts[1]));
}
}
if (!string.IsNullOrWhiteSpace(uri.Owner) && !string.IsNullOrWhiteSpace(uri.RepositoryName))
{
return new RepositoryModel(uri.RepositoryName, uri);
}
}
return null;
}
}
}

Просмотреть файл

@ -62,40 +62,52 @@ namespace GitHub.ViewModels.Dialog
public async Task StartWithConnection<T>(T viewModel)
where T : IDialogContentViewModel, IConnectionInitializedViewModel
{
var connections = await connectionManager.Value.GetLoadedConnections();
var connections = await connectionManager.Value.GetLoadedConnections().ConfigureAwait(true);
var connection = connections.FirstOrDefault(x => x.IsLoggedIn);
if (connection == null)
{
var login = CreateLoginViewModel();
connection = await ShowLogin().ConfigureAwait(true);
}
subscription = login.Done.Take(1).Subscribe(async x =>
{
var newConnection = (IConnection)x;
if (newConnection != null)
{
await viewModel.InitializeAsync(newConnection);
Start(viewModel);
}
else
{
done.OnNext(null);
}
});
Content = login;
if (connection != null)
{
await viewModel.InitializeAsync(connection).ConfigureAwait(true);
Start(viewModel);
}
else
{
await viewModel.InitializeAsync(connection);
Start(viewModel);
done.OnNext(null);
}
}
ILoginViewModel CreateLoginViewModel()
public async Task StartWithLogout<T>(T viewModel, IConnection connection)
where T : IDialogContentViewModel, IConnectionInitializedViewModel
{
return factory.CreateViewModel<ILoginViewModel>();
var logout = factory.CreateViewModel<ILogOutRequiredViewModel>();
subscription?.Dispose();
subscription = logout.Done.Take(1).Subscribe(async _ =>
{
await connectionManager.Value.LogOut(connection.HostAddress).ConfigureAwait(true);
connection = await ShowLogin().ConfigureAwait(true);
if (connection != null)
{
await viewModel.InitializeAsync(connection).ConfigureAwait(true);
Start(viewModel);
}
});
Content = logout;
}
async Task<IConnection> ShowLogin()
{
var login = factory.CreateViewModel<ILoginViewModel>();
Content = login;
return (IConnection)await login.Done.Take(1);
}
}
}

Просмотреть файл

@ -0,0 +1,25 @@
using System;
using System.ComponentModel.Composition;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog;
using ReactiveUI;
namespace GitHub.App.ViewModels.Dialog
{
/// <summary>
/// The "Logout required" dialog page.
/// </summary>
[Export(typeof(ILogOutRequiredViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class LogOutRequiredViewModel : ViewModelBase, ILogOutRequiredViewModel
{
/// <inheritdoc/>
public ReactiveCommand<object> LogOut { get; } = ReactiveCommand.Create();
/// <inheritdoc/>
public string Title => Resources.LogoutRequired;
/// <inheritdoc/>
public IObservable<object> Done => LogOut;
}
}

Просмотреть файл

@ -1,280 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel.Composition;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using GitHub.App;
using GitHub.Collections;
using GitHub.Extensions;
using GitHub.Factories;
using GitHub.Logging;
using GitHub.Models;
using GitHub.Services;
using GitHub.Validation;
using ReactiveUI;
using Rothko;
using Serilog;
namespace GitHub.ViewModels.Dialog
{
[Export(typeof(IRepositoryCloneViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class RepositoryCloneViewModel : ViewModelBase, IRepositoryCloneViewModel
{
static readonly ILogger log = LogManager.ForContext<RepositoryCloneViewModel>();
readonly IModelServiceFactory modelServiceFactory;
readonly IOperatingSystem operatingSystem;
readonly ReactiveCommand<object> browseForDirectoryCommand = ReactiveCommand.Create();
bool noRepositoriesFound;
readonly ObservableAsPropertyHelper<bool> canClone;
string baseRepositoryPath;
bool loadingFailed;
[ImportingConstructor]
public RepositoryCloneViewModel(
IModelServiceFactory modelServiceFactory,
IRepositoryCloneService cloneService,
IOperatingSystem operatingSystem)
{
Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory));
Guard.ArgumentNotNull(cloneService, nameof(cloneService));
Guard.ArgumentNotNull(operatingSystem, nameof(operatingSystem));
this.modelServiceFactory = modelServiceFactory;
this.operatingSystem = operatingSystem;
Repositories = new TrackingCollection<IRemoteRepositoryModel>();
repositories.ProcessingDelay = TimeSpan.Zero;
repositories.Comparer = OrderedComparer<IRemoteRepositoryModel>.OrderBy(x => x.Owner).ThenBy(x => x.Name).Compare;
repositories.Filter = FilterRepository;
repositories.NewerComparer = OrderedComparer<IRemoteRepositoryModel>.OrderByDescending(x => x.UpdatedAt).Compare;
filterTextIsEnabled = this.WhenAny(x => x.IsBusy,
loading => loading.Value || repositories.UnfilteredCount > 0 && !LoadingFailed)
.ToProperty(this, x => x.FilterTextIsEnabled);
this.WhenAny(
x => x.repositories.UnfilteredCount,
x => x.IsBusy,
x => x.LoadingFailed,
(unfilteredCount, loading, failed) =>
{
if (loading.Value)
return false;
if (failed.Value)
return false;
return unfilteredCount.Value == 0;
})
.Subscribe(x =>
{
NoRepositoriesFound = x;
});
this.WhenAny(x => x.FilterText, x => x.Value)
.DistinctUntilChanged(StringComparer.OrdinalIgnoreCase)
.Throttle(TimeSpan.FromMilliseconds(100), RxApp.MainThreadScheduler)
.Subscribe(_ => repositories.Filter = FilterRepository);
var baseRepositoryPath = this.WhenAny(
x => x.BaseRepositoryPath,
x => x.SelectedRepository,
(x, y) => x.Value);
BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(baseRepositoryPath)
.IfNullOrEmpty(Resources.RepositoryCreationClonePathEmpty)
.IfTrue(x => x.Length > 200, Resources.RepositoryCreationClonePathTooLong)
.IfContainsInvalidPathChars(Resources.RepositoryCreationClonePathInvalidCharacters)
.IfPathNotRooted(Resources.RepositoryCreationClonePathInvalid)
.IfTrue(IsAlreadyRepoAtPath, Resources.RepositoryNameValidatorAlreadyExists);
var canCloneObservable = this.WhenAny(
x => x.SelectedRepository,
x => x.BaseRepositoryPathValidator.ValidationResult.IsValid,
(x, y) => x.Value != null && y.Value);
canClone = canCloneObservable.ToProperty(this, x => x.CanClone);
CloneCommand = ReactiveCommand.Create(canCloneObservable);
Done = CloneCommand.Select(_ => new CloneDialogResult(BaseRepositoryPath, SelectedRepository));
browseForDirectoryCommand.Subscribe(_ => ShowBrowseForDirectoryDialog());
this.WhenAny(x => x.BaseRepositoryPathValidator.ValidationResult, x => x.Value)
.Subscribe();
BaseRepositoryPath = cloneService.DefaultClonePath;
NoRepositoriesFound = true;
}
public async Task InitializeAsync(IConnection connection)
{
var modelService = await modelServiceFactory.CreateAsync(connection);
Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, connection.HostAddress.Title);
IsBusy = true;
modelService.GetRepositories(repositories);
repositories.OriginalCompleted
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(
_ => { }
, ex =>
{
LoadingFailed = true;
IsBusy = false;
log.Error(ex, "Error while loading repositories");
},
() => IsBusy = false
);
repositories.Subscribe();
}
bool FilterRepository(IRemoteRepositoryModel repo, int position, IList<IRemoteRepositoryModel> list)
{
Guard.ArgumentNotNull(repo, nameof(repo));
Guard.ArgumentNotNull(list, nameof(list));
if (string.IsNullOrWhiteSpace(FilterText))
return true;
// Not matching on NameWithOwner here since that's already been filtered on by the selected account
return repo.Name.IndexOf(FilterText ?? "", StringComparison.OrdinalIgnoreCase) != -1;
}
bool IsAlreadyRepoAtPath(string path)
{
Guard.ArgumentNotEmptyString(path, nameof(path));
bool isAlreadyRepoAtPath = false;
if (SelectedRepository != null)
{
string potentialPath = Path.Combine(path, SelectedRepository.Name);
isAlreadyRepoAtPath = operatingSystem.Directory.Exists(potentialPath);
}
return isAlreadyRepoAtPath;
}
IObservable<Unit> ShowBrowseForDirectoryDialog()
{
return Observable.Start(() =>
{
// We store this in a local variable to prevent it changing underneath us while the
// folder dialog is open.
var localBaseRepositoryPath = BaseRepositoryPath;
var browseResult = operatingSystem.Dialog.BrowseForDirectory(localBaseRepositoryPath, Resources.BrowseForDirectory);
if (!browseResult.Success)
return;
var directory = browseResult.DirectoryPath ?? localBaseRepositoryPath;
try
{
BaseRepositoryPath = directory;
}
catch (Exception e)
{
// TODO: We really should limit this to exceptions we know how to handle.
log.Error(e, "Failed to set base repository path. {@Repository}",
new { localBaseRepositoryPath, BaseRepositoryPath, directory });
}
}, RxApp.MainThreadScheduler);
}
/// <summary>
/// Gets the title for the dialog.
/// </summary>
public string Title { get; private set; }
/// <summary>
/// Path to clone repositories into
/// </summary>
public string BaseRepositoryPath
{
get { return baseRepositoryPath; }
set { this.RaiseAndSetIfChanged(ref baseRepositoryPath, value); }
}
/// <summary>
/// Signals that the user clicked the clone button.
/// </summary>
public IReactiveCommand<object> CloneCommand { get; private set; }
bool isBusy;
public bool IsBusy
{
get { return isBusy; }
private set { this.RaiseAndSetIfChanged(ref isBusy, value); }
}
TrackingCollection<IRemoteRepositoryModel> repositories;
public ObservableCollection<IRemoteRepositoryModel> Repositories
{
get { return repositories; }
private set { this.RaiseAndSetIfChanged(ref repositories, (TrackingCollection<IRemoteRepositoryModel>)value); }
}
IRepositoryModel selectedRepository;
/// <summary>
/// Selected repository to clone
/// </summary>
public IRepositoryModel SelectedRepository
{
get { return selectedRepository; }
set { this.RaiseAndSetIfChanged(ref selectedRepository, value); }
}
readonly ObservableAsPropertyHelper<bool> filterTextIsEnabled;
/// <summary>
/// True if there are repositories (otherwise no point in filtering)
/// </summary>
public bool FilterTextIsEnabled { get { return filterTextIsEnabled.Value; } }
string filterText;
/// <summary>
/// User text to filter the repositories list
/// </summary>
public string FilterText
{
get { return filterText; }
set { this.RaiseAndSetIfChanged(ref filterText, value); }
}
public bool LoadingFailed
{
get { return loadingFailed; }
private set { this.RaiseAndSetIfChanged(ref loadingFailed, value); }
}
public bool NoRepositoriesFound
{
get { return noRepositoriesFound; }
private set { this.RaiseAndSetIfChanged(ref noRepositoriesFound, value); }
}
public ICommand BrowseForDirectory
{
get { return browseForDirectoryCommand; }
}
public bool CanClone
{
get { return canClone.Value; }
}
public ReactivePropertyValidator<string> BaseRepositoryPathValidator
{
get;
private set;
}
public IObservable<object> Done { get; }
}
}

Просмотреть файл

@ -1,6 +1,8 @@
using System;
using System.Reactive;
using System.Collections.Generic;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Primitives;
namespace GitHub.Services
{
@ -32,5 +34,16 @@ namespace GitHub.Services
string repositoryName,
string repositoryPath,
object progress = null);
/// <summary>
/// Checks whether the specified destination path 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.
/// </returns>
bool DestinationExists(string path);
Task<ViewerRepositoriesModel> ReadViewerRepositories(HostAddress address);
}
}

Просмотреть файл

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using GitHub.Api;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog;
@ -27,6 +30,22 @@ namespace GitHub.Services
/// </returns>
Task<object> Show(IDialogContentViewModel viewModel);
/// <summary>
/// Shows a view model that requires a connection with specifiec scopes in the dialog.
/// </summary>
/// <param name="viewModel">The view model to show.</param>
/// <param name="connection">The connection.</param>
/// <param name="scopes">The required scopes.</param>
/// <returns>
/// If the connection does not have the requested scopes, the user will be invited to log
/// out and back in.
/// </returns>
Task<object> Show<TViewModel>(
TViewModel viewModel,
IConnection connection,
IEnumerable<string> scopes)
where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel;
/// <summary>
/// Shows a view model that requires a connection in the dialog.
/// </summary>

Просмотреть файл

@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
using GitHub.Models;
namespace GitHub.ViewModels.Dialog.Clone
{
/// <summary>
/// Represents a tab in the repository clone dialog.
/// </summary>
public interface IRepositoryCloneTabViewModel : IViewModel
{
/// <summary>
/// Gets a value that indicates whether the tab is enabled.
/// </summary>
/// <remarks>
/// A disabled tab will be hidden.
/// </remarks>
bool IsEnabled { get; }
/// <summary>
/// Gets the selected repository, or null if no repository has been selected.
/// </summary>
IRepositoryModel Repository { get; }
/// <summary>
/// Activates the tab.
/// </summary>
/// <remarks>
/// Will be called each time the tab is selected.
/// </remarks>
Task Activate();
}
}

Просмотреть файл

@ -0,0 +1,55 @@
using System;
using GitHub.Models;
using ReactiveUI;
namespace GitHub.ViewModels.Dialog.Clone
{
/// <summary>
/// ViewModel for the the Clone Repository dialog
/// </summary>
public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel
{
/// <summary>
/// Gets the view model for the GitHub.com tab.
/// </summary>
IRepositorySelectViewModel GitHubTab { get; }
/// <summary>
/// Gets the view model for the enterprise tab.
/// </summary>
IRepositorySelectViewModel EnterpriseTab { get; }
/// <summary>
/// Gets the view model for the URL tab.
/// </summary>
IRepositoryUrlViewModel UrlTab { get; }
/// <summary>
/// Gets the path to clone the repository to.
/// </summary>
string Path { get; set; }
/// <summary>
/// Gets an error message that explains why <see cref="Path"/> is not valid.
/// </summary>
string PathError { get; }
/// <summary>
/// Gets the index of the selected tab.
/// </summary>
/// <remarks>
/// The tabs are: GitHubPage, EnterprisePage, UrlPage.
/// </remarks>
int SelectedTabIndex { get; }
/// <summary>
/// Gets the command executed when the user clicks "Browse".
/// </summary>
ReactiveCommand<object> Browse { get; }
/// <summary>
/// Gets the command executed when the user clicks "Clone".
/// </summary>
ReactiveCommand<CloneDialogResult> Clone { get; }
}
}

Просмотреть файл

@ -0,0 +1,14 @@
using System;
using GitHub.UI;
namespace GitHub.ViewModels.Dialog.Clone
{
public interface IRepositoryItemViewModel
{
string Caption { get; }
string Name { get; }
string Owner { get; }
Octicon Icon { get; }
Uri Url { get; }
}
}

Просмотреть файл

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using GitHub.Models;
namespace GitHub.ViewModels.Dialog.Clone
{
public interface IRepositorySelectViewModel : IRepositoryCloneTabViewModel
{
Exception Error { get; }
string Filter { get; set; }
bool IsLoading { get; }
IReadOnlyList<IRepositoryItemViewModel> Items { get; }
ICollectionView ItemsView { get; }
IRepositoryItemViewModel SelectedItem { get; set; }
void Initialize(IConnection connection);
}
}

Просмотреть файл

@ -0,0 +1,9 @@
using System;
namespace GitHub.ViewModels.Dialog.Clone
{
public interface IRepositoryUrlViewModel : IRepositoryCloneTabViewModel
{
string Url { get; set; }
}
}

Просмотреть файл

@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using GitHub.Models;
namespace GitHub.ViewModels.Dialog
{
@ -33,5 +34,13 @@ namespace GitHub.ViewModels.Dialog
/// <param name="viewModel">The view model to display.</param>
Task StartWithConnection<T>(T viewModel)
where T : IDialogContentViewModel, IConnectionInitializedViewModel;
/// <summary>
/// Starts displaying a view model that requires a connection that needs to be logged out
/// and back in.
/// </summary>
/// <param name="viewModel">The view model to display.</param>
Task StartWithLogout<T>(T viewModel, IConnection connection)
where T : IDialogContentViewModel, IConnectionInitializedViewModel;
}
}

Просмотреть файл

@ -0,0 +1,16 @@
using System;
using ReactiveUI;
namespace GitHub.ViewModels.Dialog
{
/// <summary>
/// Represents the "Logout required" dialog page.
/// </summary>
public interface ILogOutRequiredViewModel : IDialogContentViewModel
{
/// <summary>
/// Gets a command that will log out the user.
/// </summary>
ReactiveCommand<object> LogOut { get; }
}
}

Просмотреть файл

@ -1,36 +0,0 @@
using System;
using GitHub.Models;
using System.Collections.ObjectModel;
namespace GitHub.ViewModels.Dialog
{
/// <summary>
/// ViewModel for the the Clone Repository dialog
/// </summary>
public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel
{
/// <summary>
/// The list of repositories the current user may clone from the specified host.
/// </summary>
ObservableCollection<IRemoteRepositoryModel> Repositories { get; }
bool FilterTextIsEnabled { get; }
/// <summary>
/// If true, then we failed to load the repositories.
/// </summary>
bool LoadingFailed { get; }
/// <summary>
/// Set to true if no repositories were found.
/// </summary>
bool NoRepositoriesFound { get; }
/// <summary>
/// Set to true if a repository is selected.
/// </summary>
bool CanClone { get; }
string FilterText { get; set; }
}
}

Просмотреть файл

@ -1,5 +1,4 @@
using System;
using GitHub.Services;
namespace GitHub.Models
{
@ -11,18 +10,18 @@ namespace GitHub.Models
/// <summary>
/// Initializes a new instance of the <see cref="CloneDialogResult"/> class.
/// </summary>
/// <param name="basePath">The selected base path for the clone.</param>
/// <param name="path">The path to clone the repository to.</param>
/// <param name="repository">The selected repository.</param>
public CloneDialogResult(string basePath, IRepositoryModel repository)
public CloneDialogResult(string path, IRepositoryModel repository)
{
BasePath = basePath;
Path = path;
Repository = repository;
}
/// <summary>
/// Gets the filesystem path to which the user wants to clone.
/// Gets the path to clone the repository to.
/// </summary>
public string BasePath { get; }
public string Path { get; }
/// <summary>
/// Gets the repository selected by the user.

Просмотреть файл

@ -1,6 +1,5 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using GitHub.Primitives;
using Octokit;
@ -29,6 +28,11 @@ namespace GitHub.Models
/// </remarks>
User User { get; }
/// <summary>
/// Gets the login scopes.
/// </summary>
ScopesCollection Scopes { get; }
/// <summary>
/// Gets a value indicating whether the login of the account succeeded.
/// </summary>

Просмотреть файл

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
namespace GitHub.Models
{
public class OrganizationRepositoriesModel
{
public ActorModel Organization { get; set; }
public IReadOnlyList<RepositoryListItemModel> Repositories { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,13 @@
using System;
namespace GitHub.Models
{
public class RepositoryListItemModel
{
public bool IsFork { get; set; }
public bool IsPrivate { get; set; }
public string Name { get; set; }
public string Owner { get; set; }
public Uri Url { get; set; }
}
}

Просмотреть файл

@ -0,0 +1,72 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace GitHub.Models
{
/// <summary>
/// A collection of OAuth scopes.
/// </summary>
public class ScopesCollection : IReadOnlyList<string>
{
readonly IReadOnlyList<string> inner;
/// <summary>
/// Initializes a new instance of the <see cref="Scopes"/> class.
/// </summary>
/// <param name="scopes">The scopes.</param>
public ScopesCollection(IReadOnlyList<string> scopes)
{
inner = scopes;
}
/// <inheritdoc/>
public string this[int index] => inner[index];
/// <inheritdoc/>
public int Count => inner.Count;
/// <inheritdoc/>
public IEnumerator<string> GetEnumerator() => inner.GetEnumerator();
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator() => inner.GetEnumerator();
/// <summary>
/// Tests if received API scopes match the required API scopes.
/// </summary>
/// <param name="required">The required API scopes.</param>
/// <returns>True if all required scopes are present, otherwise false.</returns>
public bool Matches(IEnumerable<string> required)
{
foreach (var scope in required)
{
var found = inner.Contains(scope);
if (!found &&
(scope.StartsWith("read:", StringComparison.Ordinal) ||
scope.StartsWith("write:", StringComparison.Ordinal)))
{
// NOTE: Scopes are actually more complex than this, for example
// `user` encompasses `read:user` and `user:email` but just use
// this simple rule for now as it works for the scopes we require.
var adminScope = scope
.Replace("read:", "admin:")
.Replace("write:", "admin:");
found = inner.Contains(adminScope);
}
if (!found)
{
return false;
}
}
return true;
}
/// <inheritdoc/>
public override string ToString() => string.Join(",", inner);
}
}

Просмотреть файл

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
namespace GitHub.Models
{
public class ViewerRepositoriesModel
{
public IReadOnlyList<RepositoryListItemModel> Repositories { get; set; }
public IDictionary<string, IReadOnlyList<RepositoryListItemModel>> OrganizationRepositories { get; set; }
}
}

Просмотреть файл

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using GitHub.Models;
@ -13,12 +14,16 @@ namespace GitHub.Services
{
string username;
Octokit.User user;
ScopesCollection scopes;
bool isLoggedIn;
bool isLoggingIn;
Exception connectionError;
public Connection(HostAddress hostAddress)
public Connection(
HostAddress hostAddress,
string username)
{
this.username = username;
HostAddress = hostAddress;
isLoggedIn = false;
isLoggingIn = true;
@ -26,12 +31,12 @@ namespace GitHub.Services
public Connection(
HostAddress hostAddress,
string username,
Octokit.User user)
Octokit.User user,
ScopesCollection scopes)
{
HostAddress = hostAddress;
this.username = username;
this.user = user;
this.scopes = scopes;
isLoggedIn = true;
}
@ -44,7 +49,6 @@ namespace GitHub.Services
get => username;
private set => RaiseAndSetIfChanged(ref username, value);
}
/// <inheritdoc/>
public Octokit.User User
{
@ -52,6 +56,13 @@ namespace GitHub.Services
private set => RaiseAndSetIfChanged(ref user, value);
}
/// <inheritdoc/>
public ScopesCollection Scopes
{
get => scopes;
private set => RaiseAndSetIfChanged(ref scopes, value);
}
/// <inheritdoc/>
public bool IsLoggedIn
{
@ -82,7 +93,7 @@ namespace GitHub.Services
IsLoggedIn = false;
IsLoggingIn = true;
User = null;
Username = null;
Scopes = null;
}
public void SetError(Exception e)
@ -90,12 +101,13 @@ namespace GitHub.Services
ConnectionError = e;
IsLoggingIn = false;
IsLoggedIn = false;
Scopes = null;
}
public void SetSuccess(Octokit.User user)
public void SetSuccess(Octokit.User user, ScopesCollection scopes)
{
User = user;
Username = user.Login;
Scopes = scopes;
IsLoggingIn = false;
IsLoggedIn = true;
}

Просмотреть файл

@ -1,14 +0,0 @@
namespace GitHub.ViewModels
{
public interface IInfoPanel
{
string Message { get; set; }
MessageType MessageType { get; set; }
}
public enum MessageType
{
Information,
Warning
}
}

Просмотреть файл

@ -370,14 +370,6 @@
<HintPath>..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Octokit.GraphQL, Version=0.1.0.0, Culture=neutral, PublicKeyToken=0be8860aee462442, processorArchitecture=MSIL">
<HintPath>..\..\packages\Octokit.GraphQL.0.1.0-beta\lib\netstandard1.1\Octokit.GraphQL.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Octokit.GraphQL.Core, Version=0.1.0.0, Culture=neutral, PublicKeyToken=0be8860aee462442, processorArchitecture=MSIL">
<HintPath>..\..\packages\Octokit.GraphQL.0.1.0-beta\lib\netstandard1.1\Octokit.GraphQL.Core.dll</HintPath>
<Private>True</Private>
</Reference>
<Reference Include="Octokit.GraphQL, Version=0.1.1.0, Culture=neutral, PublicKeyToken=0be8860aee462442, processorArchitecture=MSIL">
<HintPath>..\..\packages\Octokit.GraphQL.0.1.1-beta\lib\netstandard1.1\Octokit.GraphQL.dll</HintPath>
</Reference>

Просмотреть файл

@ -70,11 +70,10 @@ namespace GitHub.StartPage
if (request == null)
return null;
var path = Path.Combine(request.BasePath, request.Repository.Name);
var uri = request.Repository.CloneUrl.ToRepositoryUrl();
return new CodeContainer(
localProperties: new CodeContainerLocalProperties(path, CodeContainerType.Folder,
new CodeContainerSourceControlProperties(request.Repository.Name, path, new Guid(Guids.GitSccProviderId))),
localProperties: new CodeContainerLocalProperties(request.Path, CodeContainerType.Folder,
new CodeContainerSourceControlProperties(request.Repository.Name, request.Path, new Guid(Guids.GitSccProviderId))),
remote: new RemoteCodeContainer(request.Repository.Name,
new Guid(Guids.CodeContainerProviderId),
uri,
@ -144,7 +143,7 @@ namespace GitHub.StartPage
await cloneService.CloneRepository(
result.Repository.CloneUrl,
result.Repository.Name,
result.BasePath,
result.Path,
progress);
usageTracker.IncrementCounter(x => x.NumberOfStartPageClones).Forget();

Просмотреть файл

@ -154,7 +154,7 @@ namespace GitHub.VisualStudio.TeamExplorer.Connect
await cloneService.CloneRepository(
result.Repository.CloneUrl,
result.Repository.Name,
result.BasePath);
result.Path);
usageTracker.IncrementCounter(x => x.NumberOfGitHubConnectSectionClones).Forget();
}

Просмотреть файл

@ -55,7 +55,7 @@
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource GHLinkButtonTextBrush}"/>
<Setter Property="Margin" Value="0,0,0,0"/>
<Setter Property="Padding" Value="0,0,12,0"/>
<Setter Property="Padding" Value="0,2,12,2"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="VerticalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
@ -63,7 +63,7 @@
<ControlTemplate TargetType="{x:Type TabItem}">
<Grid
x:Name="templateRoot"
Margin="0,2,0,2"
Margin="{TemplateBinding Padding}"
SnapsToDevicePixels="true"
Background="{TemplateBinding Background}">
<Grid.RowDefinitions>

Просмотреть файл

@ -0,0 +1,31 @@
using System;
using System.Globalization;
using System.Windows;
namespace GitHub.UI
{
[Localizability(LocalizationCategory.NeverLocalize)]
public sealed class NullOrWhitespaceToVisibilityConverter : ValueConverterMarkupExtension<NullOrWhitespaceToVisibilityConverter>
{
public override object Convert(object value,
Type targetType,
object parameter,
CultureInfo culture)
{
if (value is string s)
{
return string.IsNullOrWhiteSpace(s) ? Visibility.Collapsed : Visibility.Visible;
}
return Visibility.Collapsed;
}
public override object ConvertBack(object value,
Type targetType,
object parameter,
CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

Просмотреть файл

@ -16,6 +16,7 @@
<cache:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/Styles/GitHubActionLink.xaml"/>
<cache:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/Styles/ItemsControls.xaml"/>
<cache:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/Styles/SectionControl.xaml"/>
<cache:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/UI/Controls/InfoPanel.xaml"/>
</ResourceDictionary.MergedDictionaries>
<DrawingBrush x:Key="ConnectArrowBrush">

Просмотреть файл

@ -0,0 +1,6 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/GitHub.VisualStudio.UI;component/UI/Controls/InfoPanel.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

Просмотреть файл

@ -0,0 +1,107 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using GitHub.Services;
using GitHub.UI;
namespace GitHub.VisualStudio.UI.Controls
{
/// <summary>
/// Displays informational or error message markdown in a banner.
/// </summary>
public class InfoPanel : Control
{
public static readonly DependencyProperty MessageProperty =
DependencyProperty.Register(
nameof(Message),
typeof(string),
typeof(InfoPanel));
public static readonly DependencyProperty IconProperty =
DependencyProperty.Register(
nameof(Icon),
typeof(Octicon),
typeof(InfoPanel),
new FrameworkPropertyMetadata(Octicon.info));
public static readonly DependencyProperty ShowCloseButtonProperty =
DependencyProperty.Register(
nameof(ShowCloseButton),
typeof(bool),
typeof(InfoPanel));
static IVisualStudioBrowser browser;
Button closeButton;
static InfoPanel()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(InfoPanel),
new FrameworkPropertyMetadata(typeof(InfoPanel)));
DockPanel.DockProperty.OverrideMetadata(
typeof(InfoPanel),
new FrameworkPropertyMetadata(Dock.Top));
}
public InfoPanel()
{
var commandBinding = new CommandBinding(Markdig.Wpf.Commands.Hyperlink);
commandBinding.Executed += OpenHyperlink;
CommandBindings.Add(commandBinding);
}
/// <summary>
/// Gets or sets the message in markdown.
/// </summary>
public string Message
{
get => (string)GetValue(MessageProperty);
set => SetValue(MessageProperty, value);
}
/// <summary>
/// Gets or sets the icon to display.
/// </summary>
public Octicon Icon
{
get => (Octicon)GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
public bool ShowCloseButton
{
get => (bool)GetValue(ShowCloseButtonProperty);
set => SetValue(ShowCloseButtonProperty, value);
}
static IVisualStudioBrowser Browser
{
get
{
if (browser == null)
browser = Services.GitHubServiceProvider.TryGetService<IVisualStudioBrowser>();
return browser;
}
}
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
closeButton = (Button)Template.FindName("PART_CloseButton", this);
closeButton.Click += CloseButtonClicked;
}
void CloseButtonClicked(object sender, RoutedEventArgs e)
{
Message = null;
}
void OpenHyperlink(object sender, ExecutedRoutedEventArgs e)
{
var url = e.Parameter.ToString();
if (!string.IsNullOrEmpty(url))
Browser.OpenUrl(new Uri(url));
}
}
}

Просмотреть файл

@ -1,67 +1,65 @@
<UserControl x:Class="GitHub.VisualStudio.UI.Controls.InfoPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ghfvs="https://github.com/github/VisualStudio"
xmlns:local="clr-namespace:GitHub.VisualStudio.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:markdig="clr-namespace:Markdig.Wpf;assembly=Markdig.Wpf"
Background="{DynamicResource VsBrush.InfoBackground}"
d:DataContext="{d:DesignInstance Type=ghfvs:InfoPanelDesigner, IsDesignTimeCreatable=True}"
d:DesignHeight="300"
d:DesignWidth="300"
mc:Ignorable="d"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.GitHubInfoPanel}">
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ghfvs="https://github.com/github/VisualStudio"
xmlns:local="clr-namespace:GitHub.VisualStudio.UI.Controls"
xmlns:markdig="clr-namespace:Markdig.Wpf;assembly=Markdig.Wpf">
<Style TargetType="local:InfoPanel">
<Setter Property="Background" Value="{DynamicResource VsBrush.InfoBackground}"/>
<Setter Property="Padding" Value="8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:InfoPanel">
<Border Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Visibility="{TemplateBinding Message, Converter={ghfvs:NullOrWhitespaceToVisibilityConverter}}">
<DockPanel>
<DockPanel.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/SharedDictionary.xaml"/>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/Assets/Markdown.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style x:Key="DocumentStyle" TargetType="FlowDocument">
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="TextAlignment" Value="Left"/>
<Setter Property="PagePadding" Value="0"/>
</Style>
<Style x:Key="LinkStyle" TargetType="Hyperlink">
<Setter Property="Foreground" Value="{DynamicResource GitHubActionLinkItemBrush}"/>
<Setter Property="TextDecorations" Value="Underline"/>
</Style>
</ResourceDictionary>
</DockPanel.Resources>
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/SharedDictionary.xaml"/>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/Assets/Markdown.xaml" />
</ResourceDictionary.MergedDictionaries>
<Style x:Key="DocumentStyle" TargetType="FlowDocument">
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="TextAlignment" Value="Left"/>
<Setter Property="PagePadding" Value="0"/>
</Style>
<Style x:Key="LinkStyle" TargetType="Hyperlink">
<Setter Property="Foreground" Value="{DynamicResource GitHubActionLinkItemBrush}"/>
<Setter Property="TextDecorations" Value="Underline"/>
</Style>
</ResourceDictionary>
</UserControl.Resources>
<FrameworkElement.CommandBindings>
<CommandBinding Command="{x:Static markdig:Commands.Hyperlink}" Executed="OpenHyperlink" />
</FrameworkElement.CommandBindings>
<DockPanel Margin="8" LastChildFill="True">
<ghfvs:OcticonImage Width="16"
Height="16"
Margin="0,0,8,0"
VerticalAlignment="Top"
DockPanel.Dock="Left"
Foreground="{Binding IconColor}"
Icon="{Binding Icon}"/>
<ghfvs:OcticonButton Width="16"
Height="16"
Margin="0"
VerticalAlignment="Top"
Background="Transparent"
Click="Dismiss_Click"
DockPanel.Dock="Right"
Foreground="Black"
Icon="x"/>
<markdig:MarkdownViewer x:Name="document"
Margin="8,0"
VerticalAlignment="Top"
DockPanel.Dock="Top"
Foreground="Black"
Markdown="{Binding Message}"
ScrollViewer.CanContentScroll="False"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.VerticalScrollBarVisibility="Hidden"/>
</DockPanel>
</UserControl>
<ghfvs:OcticonImage Width="16"
Height="16"
Margin="0,0,8,0"
VerticalAlignment="Top"
DockPanel.Dock="Left"
Icon="{TemplateBinding Icon}"/>
<ghfvs:OcticonButton x:Name="PART_CloseButton"
Width="16"
Height="16"
Margin="0"
VerticalAlignment="Top"
Background="Transparent"
DockPanel.Dock="Right"
Foreground="Black"
Icon="x"
Visibility="{TemplateBinding ShowCloseButton, Converter={ghfvs:BooleanToVisibilityConverter}}"/>
<markdig:MarkdownViewer Margin="8,0"
VerticalAlignment="Top"
DockPanel.Dock="Top"
Foreground="Black"
Markdown="{TemplateBinding Message}"
ScrollViewer.CanContentScroll="False"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.VerticalScrollBarVisibility="Hidden"/>
</DockPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

Просмотреть файл

@ -1,114 +0,0 @@
using GitHub.UI;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using GitHub.ViewModels;
using System.ComponentModel;
using GitHub.Services;
using GitHub.Extensions;
using System.Windows.Input;
using GitHub.Primitives;
using GitHub.VisualStudio.Helpers;
using Colors = System.Windows.Media.Colors;
namespace GitHub.VisualStudio.UI.Controls
{
public partial class InfoPanel : UserControl, IInfoPanel, INotifyPropertyChanged, INotifyPropertySource
{
static SolidColorBrush WarningColorBrush = new SolidColorBrush(Colors.DarkRed);
static SolidColorBrush InfoColorBrush = new SolidColorBrush(Colors.Black);
static readonly DependencyProperty MessageProperty =
DependencyProperty.Register(nameof(Message), typeof(string), typeof(InfoPanel), new PropertyMetadata(String.Empty, UpdateMessage));
static readonly DependencyProperty MessageTypeProperty =
DependencyProperty.Register(nameof(MessageType), typeof(MessageType), typeof(InfoPanel), new PropertyMetadata(MessageType.Information, UpdateIcon));
public string Message
{
get { return (string)GetValue(MessageProperty); }
set { SetValue(MessageProperty, value); }
}
public MessageType MessageType
{
get { return (MessageType)GetValue(MessageTypeProperty); }
set { SetValue(MessageTypeProperty, value); }
}
Octicon icon;
public Octicon Icon
{
get { return icon; }
private set { icon = value; RaisePropertyChanged(nameof(Icon)); }
}
Brush iconColor;
public Brush IconColor
{
get { return iconColor; }
private set { iconColor = value; RaisePropertyChanged(nameof(IconColor)); }
}
static InfoPanel()
{
WarningColorBrush.Freeze();
InfoColorBrush.Freeze();
}
static IVisualStudioBrowser browser;
static IVisualStudioBrowser Browser
{
get
{
if (browser == null)
browser = Services.GitHubServiceProvider.TryGetService<IVisualStudioBrowser>();
return browser;
}
}
public InfoPanel()
{
InitializeComponent();
DataContext = this;
Icon = Octicon.info;
IconColor = InfoColorBrush;
}
void OpenHyperlink(object sender, ExecutedRoutedEventArgs e)
{
var url = e.Parameter.ToString();
if (!string.IsNullOrEmpty(url))
Browser.OpenUrl(new Uri(url));
}
static void UpdateMessage(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (InfoPanel)d;
var msg = e.NewValue as string;
control.Visibility = String.IsNullOrEmpty(msg) ? Visibility.Collapsed : Visibility.Visible;
}
static void UpdateIcon(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = (InfoPanel)d;
control.Icon = (MessageType)e.NewValue == MessageType.Warning ? Octicon.alert : Octicon.info;
control.IconColor = control.Icon == Octicon.alert ? WarningColorBrush : InfoColorBrush;
}
void Dismiss_Click(object sender, RoutedEventArgs e)
{
SetCurrentValue(MessageProperty, String.Empty);
}
public event PropertyChangedEventHandler PropertyChanged;
public void RaisePropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

Просмотреть файл

@ -384,6 +384,12 @@
</Compile>
<Compile Include="Views\ActorAvatarView.cs" />
<Compile Include="Views\ContentView.cs" />
<Compile Include="Views\Dialog\Clone\RepositoryCloneView.xaml.cs">
<DependentUpon>RepositoryCloneView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\Dialog\Clone\SelectPageView.xaml.cs">
<DependentUpon>SelectPageView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\Dialog\ForkRepositoryExecuteView.xaml.cs">
<DependentUpon>ForkRepositoryExecuteView.xaml</DependentUpon>
</Compile>
@ -402,8 +408,8 @@
<Compile Include="Views\Dialog\Login2FaView.xaml.cs">
<DependentUpon>Login2FaView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\Dialog\RepositoryCloneView.xaml.cs">
<DependentUpon>RepositoryCloneView.xaml</DependentUpon>
<Compile Include="Views\Dialog\LogOutRequiredView.xaml.cs">
<DependentUpon>LogOutRequiredView.xaml</DependentUpon>
</Compile>
<Compile Include="Views\Dialog\RepositoryCreationView.xaml.cs">
<DependentUpon>RepositoryCreationView.xaml</DependentUpon>
@ -562,6 +568,14 @@
<Generator>MSBuild:Compile</Generator>
<CustomToolNamespace>GitHub.VisualStudio.UI</CustomToolNamespace>
</Page>
<Page Include="Views\Dialog\Clone\RepositoryCloneView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="Views\Dialog\Clone\SelectPageView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\Dialog\ForkRepositoryExecuteView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
@ -586,9 +600,8 @@
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="Views\Dialog\RepositoryCloneView.xaml">
<Page Include="Views\Dialog\LogOutRequiredView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="Views\Dialog\RepositoryCreationView.xaml">
<Generator>MSBuild:Compile</Generator>

Просмотреть файл

@ -256,7 +256,8 @@ namespace GitHub.VisualStudio
oauthListener,
ApiClientConfiguration.ClientId,
ApiClientConfiguration.ClientSecret,
ApiClientConfiguration.RequiredScopes,
ApiClientConfiguration.MinimumScopes,
ApiClientConfiguration.RequestedScopes,
ApiClientConfiguration.AuthorizationNote,
ApiClientConfiguration.MachineFingerprint);
}

Просмотреть файл

@ -79,8 +79,8 @@ namespace GitHub.VisualStudio
}
var client = CreateClient(address);
var user = await loginManager.Login(address, client, userName, password);
var connection = new Connection(address, userName, user);
var login = await loginManager.Login(address, client, userName, password);
var connection = new Connection(address, login.User, login.Scopes);
conns.Add(connection);
await SaveConnections();
@ -100,8 +100,8 @@ namespace GitHub.VisualStudio
var client = CreateClient(address);
var oauthClient = new OauthClient(client.Connection);
var user = await loginManager.LoginViaOAuth(address, client, oauthClient, OpenBrowser, cancel);
var connection = new Connection(address, user.Login, user);
var login = await loginManager.LoginViaOAuth(address, client, oauthClient, OpenBrowser, cancel);
var connection = new Connection(address, login.User, login.Scopes);
conns.Add(connection);
await SaveConnections();
@ -121,8 +121,8 @@ namespace GitHub.VisualStudio
}
var client = CreateClient(address);
var user = await loginManager.LoginWithToken(address, client, token);
var connection = new Connection(address, user.Login, user);
var login = await loginManager.LoginWithToken(address, client, token);
var connection = new Connection(address, login.User, login.Scopes);
conns.Add(connection);
await SaveConnections();
@ -156,8 +156,8 @@ namespace GitHub.VisualStudio
try
{
var client = CreateClient(c.HostAddress);
var user = await loginManager.LoginFromCache(connection.HostAddress, client);
c.SetSuccess(user);
var login = await loginManager.LoginFromCache(connection.HostAddress, client);
c.SetSuccess(login.User, login.Scopes);
await usageTracker.IncrementCounter(x => x.NumberOfLogins);
}
catch (Exception e)
@ -194,19 +194,19 @@ namespace GitHub.VisualStudio
{
foreach (var c in await cache.Load())
{
var connection = new Connection(c.HostAddress);
var connection = new Connection(c.HostAddress, c.UserName);
result.Add(connection);
}
foreach (Connection connection in result)
{
var client = CreateClient(connection.HostAddress);
User user = null;
LoginResult login = null;
try
{
user = await loginManager.LoginFromCache(connection.HostAddress, client);
connection.SetSuccess(user);
login = await loginManager.LoginFromCache(connection.HostAddress, client);
connection.SetSuccess(login.User, login.Scopes);
await usageTracker.IncrementCounter(x => x.NumberOfLogins);
}
catch (Exception e)

Просмотреть файл

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.Composition;
using System.Reactive.Linq;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Services;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog;
@ -37,6 +39,34 @@ namespace GitHub.VisualStudio.UI.Services
return Task.FromResult(result);
}
public async Task<object> Show<TViewModel>(
TViewModel viewModel,
IConnection connection,
IEnumerable<string> scopes)
where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel
{
var result = default(object);
using (var dialogViewModel = CreateViewModel())
using (dialogViewModel.Done.Take(1).Subscribe(x => result = x))
{
if (!connection.Scopes.Matches(scopes))
{
await dialogViewModel.StartWithLogout(viewModel, connection);
}
else
{
await viewModel.InitializeAsync(connection);
dialogViewModel.Start(viewModel);
}
var window = new GitHubDialogWindow(dialogViewModel);
window.ShowModal();
}
return result;
}
public async Task<object> ShowWithFirstConnection<TViewModel>(TViewModel viewModel)
where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel
{

Просмотреть файл

@ -5,6 +5,7 @@ using System.Windows.Data;
using GitHub.Exports;
using GitHub.ViewModels;
using GitHub.ViewModels.Dialog;
using GitHub.ViewModels.Dialog.Clone;
namespace GitHub.VisualStudio.Views
{

Просмотреть файл

@ -0,0 +1,76 @@
<UserControl x:Class="GitHub.VisualStudio.Views.Dialog.Clone.RepositoryCloneView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ghfvs="https://github.com/github/VisualStudio"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:GitHub.VisualStudio.Views.Dialog.Clone"
mc:Ignorable="d" d:DesignHeight="414" d:DesignWidth="440">
<d:DesignData.DataContext>
<ghfvs:RepositoryCloneViewModelDesigner/>
</d:DesignData.DataContext>
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/SharedDictionary.xaml" />
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI.Reactive;component/SharedDictionary.xaml" />
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/SharedDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<DockPanel>
<ghfvs:InfoPanel Message="{Binding PathError}"/>
<ghfvs:OcticonCircleButton DockPanel.Dock="Bottom"
Margin="12"
HorizontalAlignment="Center"
Icon="check"
IsDefault="True"
Command="{Binding Clone}"
Content="Clone"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.CloneRepositoryButton}"/>
<DockPanel DockPanel.Dock="Bottom"
Margin="16">
<Label DockPanel.Dock="Left">Local Path</Label>
<Button DockPanel.Dock="Right"
Command="{Binding Browse}"
Style="{DynamicResource GitHubBlueLinkButton}"
VerticalContentAlignment="Center">
Browse
</Button>
<TextBox Text="{Binding Path, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TabControl SelectedIndex="{Binding SelectedTabIndex}"
Style="{StaticResource LightModalViewTabControl}">
<TabItem Header="GitHub.com"
Style="{DynamicResource LightModalViewTabItem}"
Visibility="{Binding GitHubTab.IsEnabled, Converter={ghfvs:BooleanToVisibilityConverter}}">
<local:SelectPageView DataContext="{Binding GitHubTab}"/>
</TabItem>
<TabItem Header="Enterprise"
Style="{DynamicResource LightModalViewTabItem}"
Visibility="{Binding EnterpriseTab.IsEnabled, Converter={ghfvs:BooleanToVisibilityConverter}}">
<local:SelectPageView DataContext="{Binding EnterpriseTab}"/>
</TabItem>
<TabItem Header="URL" Style="{DynamicResource LightModalViewTabItem}">
<DockPanel DataContext="{Binding UrlTab}"
LastChildFill="False"
Margin="8">
<TextBlock DockPanel.Dock="Top">
Repository URL or GitHub username and repository
<LineBreak/>
(<Run Background="#66d3d3d3" FontFamily="Consolas">hubot/cool-repo</Run>)
</TextBlock>
<ghfvs:PromptTextBox DockPanel.Dock="Top"
Margin="0 4"
PromptText="URL or username/repository"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</UserControl>

Просмотреть файл

@ -0,0 +1,17 @@
using System.ComponentModel.Composition;
using System.Windows.Controls;
using GitHub.Exports;
using GitHub.ViewModels.Dialog.Clone;
namespace GitHub.VisualStudio.Views.Dialog.Clone
{
[ExportViewFor(typeof(IRepositoryCloneViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public partial class RepositoryCloneView : UserControl
{
public RepositoryCloneView()
{
InitializeComponent();
}
}
}

Просмотреть файл

@ -0,0 +1,66 @@
<UserControl x:Class="GitHub.VisualStudio.Views.Dialog.Clone.SelectPageView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:GitHub.VisualStudio.Views.Dialog.Clone"
xmlns:ghfvs="https://github.com/github/VisualStudio"
mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="400">
<d:DesignData.DataContext>
<ghfvs:SelectPageViewModelDesigner/>
</d:DesignData.DataContext>
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/SharedDictionary.xaml" />
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI.Reactive;component/SharedDictionary.xaml" />
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/SharedDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<DockPanel>
<ghfvs:PromptTextBox DockPanel.Dock="Top"
Margin="0,4"
PromptText="Filter"
Text="{Binding Filter, UpdateSourceTrigger=PropertyChanged, Delay=300}"/>
<Grid>
<ListView BorderThickness="0"
ItemsSource="{Binding ItemsView}"
SelectedItem="{Binding SelectedItem}"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<ListView.GroupStyle>
<GroupStyle>
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock FontWeight="Bold"
Margin="0 4"
Text="{Binding Name}"/>
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListView.GroupStyle>
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<ghfvs:OcticonImage Icon="{Binding Icon}"/>
<TextBlock Margin="4" Text="{Binding Caption}"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<ghfvs:Spinner Visibility="{Binding IsLoading, Converter={ghfvs:BooleanToVisibilityConverter}}"
Width="32"
Height="32"/>
<TextBlock Text="{Binding Error.Message}"
Margin="4"
VerticalAlignment="Center"
HorizontalAlignment="Center"
TextAlignment="Center"
TextWrapping="Wrap"/>
</Grid>
</DockPanel>
</UserControl>

Просмотреть файл

@ -0,0 +1,23 @@
using System.ComponentModel.Composition;
using System.Windows.Controls;
using System.Windows.Input;
using GitHub.Exports;
using GitHub.ViewModels.Dialog.Clone;
namespace GitHub.VisualStudio.Views.Dialog.Clone
{
[ExportViewFor(typeof(IRepositorySelectViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public partial class SelectPageView : UserControl
{
public SelectPageView()
{
InitializeComponent();
}
protected override void OnPreviewMouseDown(MouseButtonEventArgs e)
{
base.OnPreviewMouseDown(e);
}
}
}

Просмотреть файл

@ -0,0 +1,46 @@
<UserControl x:Class="GitHub.VisualStudio.Views.Dialog.LogOutRequiredView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ghfvs="https://github.com/github/VisualStudio"
xmlns:prop="clr-namespace:GitHub.VisualStudio.UI;assembly=GitHub.VisualStudio.UI"
mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300">
<Control.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.VisualStudio.UI;component/SharedDictionary.xaml" />
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/SharedDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Control.Resources>
<DockPanel Margin="10">
<ghfvs:OcticonImage DockPanel.Dock="Top"
Icon="mark_github"
Foreground="{DynamicResource GitHubVsWindowText}"
Margin="0,5"
Width="48"
Height="48" />
<Label DockPanel.Dock="Top"
Foreground="{DynamicResource GitHubVsWindowText}"
HorizontalAlignment="Center"
FontSize="16"
Content="You need to sign out and back in." />
<ghfvs:OcticonCircleButton DockPanel.Dock="Bottom"
Command="{Binding LogOut}"
Content="Sign out"
Icon="check"
IsDefault="True"
Margin="0 16"
HorizontalAlignment="Center"/>
<TextBlock TextWrapping="Wrap"
TextAlignment="Center"
HorizontalAlignment="Center"
Text="We're sorry, but the operation you requested requires more permissions than we currently have. Please sign out and back in."/>
</DockPanel>
</UserControl>

Просмотреть файл

@ -0,0 +1,17 @@
using System.ComponentModel.Composition;
using System.Windows.Controls;
using GitHub.Exports;
using GitHub.ViewModels.Dialog;
namespace GitHub.VisualStudio.Views.Dialog
{
[ExportViewFor(typeof(ILogOutRequiredViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public partial class LogOutRequiredView : UserControl
{
public LogOutRequiredView()
{
InitializeComponent();
}
}
}

Просмотреть файл

@ -129,7 +129,7 @@
</DockPanel>
</TabItem>
<TabItem x:Name="enterpriseTab" Header="GitHub Enterprise" Margin="10,0,-10,0" AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.SignInEnterpriseHostTabItem}">
<TabItem x:Name="enterpriseTab" Header="GitHub Enterprise" AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.SignInEnterpriseHostTabItem}">
<DockPanel Style="{StaticResource TabDockPanel}">
<StackPanel DockPanel.Dock="Bottom">
<TextBlock TextWrapping="Wrap" HorizontalAlignment="Center" Margin="0" Text="{x:Static prop:Resources.dontHaveGitHubEnterpriseText}" AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.DontHaveEnterpriseTextBlock}" >

Просмотреть файл

@ -1,327 +0,0 @@
<local:GenericRepositoryCloneView x:Class="GitHub.VisualStudio.Views.Dialog.RepositoryCloneView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ghfvs="https://github.com/github/VisualStudio"
xmlns:local="clr-namespace:GitHub.VisualStudio.Views.Dialog"
xmlns:prop="clr-namespace:GitHub.VisualStudio.UI;assembly=GitHub.VisualStudio.UI"
d:DesignHeight="440"
d:DesignWidth="414"
Background="Transparent"
mc:Ignorable="d"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.RepositoryCloneControlCustom}" >
<d:DesignProperties.DataContext>
<Binding>
<Binding.Source>
<ghfvs:RepositoryCloneViewModelDesigner />
</Binding.Source>
</Binding>
</d:DesignProperties.DataContext>
<Control.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI;component/SharedDictionary.xaml" />
<ghfvs:SharedDictionaryManager Source="pack://application:,,,/GitHub.UI.Reactive;component/SharedDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Control.Resources>
<DockPanel LastChildFill="True">
<DockPanel.Resources>
<Style x:Key="repositoryBorderStyle" TargetType="Border">
<Setter Property="BorderBrush" Value="#EAEAEA" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Height" Value="30" />
<Setter Property="Margin" Value="0" />
</Style>
<Style x:Key="expanderDownHeaderStyle" TargetType="{x:Type ToggleButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ToggleButton}">
<Border Padding="{TemplateBinding Padding}" Style="{StaticResource repositoryBorderStyle}">
<StackPanel Background="#F8F8F8" Orientation="Horizontal">
<ghfvs:OcticonImage x:Name="arrow"
Height="10"
Margin="5,0,0,0"
Foreground="Black"
Icon="triangle_right" />
<ContentPresenter Margin="0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
RecognizesAccessKey="True"
SnapsToDevicePixels="True" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="arrow" Property="Icon" Value="triangle_down" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="expanderHeaderFocusVisual">
<Setter Property="Control.Template">
<Setter.Value>
<ControlTemplate>
<Border>
<Rectangle Margin="0"
SnapsToDevicePixels="true"
Stroke="Black"
StrokeDashArray="1 2"
StrokeThickness="1" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="cloneGroupExpander" TargetType="{x:Type Expander}">
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Expander}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3"
SnapsToDevicePixels="true">
<DockPanel>
<ToggleButton x:Name="HeaderSite"
MinWidth="0"
MinHeight="0"
Margin="0"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Content="{TemplateBinding Header}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
DockPanel.Dock="Top"
FocusVisualStyle="{StaticResource expanderHeaderFocusVisual}"
FontFamily="{TemplateBinding FontFamily}"
FontSize="{TemplateBinding FontSize}"
FontStretch="{TemplateBinding FontStretch}"
FontStyle="{TemplateBinding FontStyle}"
FontWeight="{TemplateBinding FontWeight}"
Foreground="{TemplateBinding Foreground}"
IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
Padding="{TemplateBinding Padding}"
Style="{StaticResource expanderDownHeaderStyle}" />
<ContentPresenter x:Name="ExpandSite"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
DockPanel.Dock="Bottom"
Focusable="false"
Visibility="Collapsed" />
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="true">
<Setter TargetName="ExpandSite" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</DockPanel.Resources>
<ghfvs:FilterTextBox x:Name="filterText"
Margin="10"
DockPanel.Dock="Top"
PromptText="{x:Static prop:Resources.filterTextPromptText}"
Text="{Binding FilterText, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding FilterTextIsEnabled, Mode=OneWay}"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.SearchRepositoryTextBox}" />
<ghfvs:GitHubProgressBar DockPanel.Dock="Top"
Foreground="{DynamicResource GitHubAccentBrush}"
IsIndeterminate="True"
Style="{DynamicResource GitHubProgressBar}"
Visibility="{Binding IsBusy, Converter={ghfvs:BooleanToHiddenVisibilityConverter}}"/>
<StackPanel x:Name="loadingFailedPanel"
Margin="0,4,0,4"
HorizontalAlignment="Center"
VerticalAlignment="Center"
DockPanel.Dock="Top">
<ghfvs:ErrorMessageDisplay x:Name="loadingFailedMessage"
Margin="8,0"
Icon="stop"
Message="{x:Static prop:Resources.loadingFailedMessageMessage}"
Visibility="{Binding LoadingFailed, Mode=OneWay, Converter={ghfvs:BooleanToVisibilityConverter}}">
<TextBlock Text="{x:Static prop:Resources.loadingFailedMessageContent}" TextWrapping="Wrap" />
</ghfvs:ErrorMessageDisplay>
</StackPanel>
<ghfvs:OcticonCircleButton x:Name="cloneButton"
Margin="12"
HorizontalAlignment="Center"
DockPanel.Dock="Bottom"
Icon="check"
IsDefault="True"
Command="{Binding CloneCommand}"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.CloneRepositoryButton}" >
<TextBlock Text="{x:Static prop:Resources.CloneLink}" />
</ghfvs:OcticonCircleButton>
<Border DockPanel.Dock="Bottom" Style="{StaticResource repositoryBorderStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ghfvs:PromptTextBox x:Name="clonePath"
Grid.Column="1"
Margin="0,2"
Text="{Binding BaseRepositoryPath, UpdateSourceTrigger=PropertyChanged}"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.CreateRepositoryLocalPathTextBox}"
/>
<Button x:Name="browsePathButton"
Grid.Column="2"
Margin="4,0,4,0"
VerticalContentAlignment="Center"
Content="{x:Static prop:Resources.browsePathButtonContent}"
Padding="0"
Style="{StaticResource GitHubBlueLinkButton}"
Command="{Binding BrowseForDirectory}"
AutomationProperties.AutomationId="{x:Static ghfvs:AutomationIDs.CloneRepositoryLocalPathBrowsePathButton}"
/>
<Label Grid.Row="3"
Grid.Column="0"
Margin="4,0"
Content="{x:Static prop:Resources.pathText}"
Target="{Binding ElementName=clonePath}" />
<ghfvs:ValidationMessage x:Name="pathValidationMessage"
Grid.Row="4"
Grid.Column="1"
ValidatesControl="{Binding ElementName=clonePath}"
ReactiveValidator="{Binding BaseRepositoryPathValidator, Mode=OneWay}"
/>
</Grid>
</Border>
<Border x:Name="repositoriesListPane"
Margin="0"
BorderBrush="#EAEAEA"
BorderThickness="0,1"
FocusManager.IsFocusScope="True"
FocusVisualStyle="{x:Null}">
<Border.Resources>
<Style BasedOn="{StaticResource GitHubTextBlock}" TargetType="{x:Type ghfvs:TrimmedTextBlock}">
<Style.Triggers>
<Trigger Property="IsTextTrimmed" Value="True">
<Setter Property="ToolTip" Value="{Binding RelativeSource={x:Static RelativeSource.Self}, Path=Text}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="cloneRepoHeaderStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource GHTextBrush}" />
<Setter Property="Margin" Value="0,6,12,6" />
</Style>
</Border.Resources>
<Grid Margin="0">
<ListBox x:Name="repositoryList"
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding Path=RepositoriesView, RelativeSource={RelativeSource AncestorType={x:Type local:RepositoryCloneView}}}"
SelectedItem="{Binding SelectedRepository}"
Style="{DynamicResource LightListBox}">
<ListBox.GroupStyle>
<GroupStyle HidesIfEmpty="true">
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type GroupItem}">
<Expander IsExpanded="{Binding Name.IsExpanded}" Style="{StaticResource cloneGroupExpander}">
<Expander.Header>
<Border Style="{StaticResource repositoryBorderStyle}">
<StackPanel Margin="0"
VerticalAlignment="Center"
Orientation="Horizontal">
<Image x:Name="avatar"
Width="16"
Height="16"
Margin="0,0,5,0"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Items[0].Owner.Avatar}" />
<TextBlock Style="{StaticResource cloneRepoHeaderStyle}" Text="{Binding Path=Name.Header}" />
</StackPanel>
</Border>
</Expander.Header>
<ItemsPresenter Margin="0" />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border Style="{StaticResource repositoryBorderStyle}">
<StackPanel Margin="0"
VerticalAlignment="Center"
Orientation="Horizontal">
<ghfvs:OcticonImage x:Name="iconPath"
Width="16"
Height="16"
Margin="32,0,6,0"
VerticalAlignment="Center"
Foreground="#D0D0D0"
Icon="{Binding Icon}" />
<ghfvs:TrimmedTextBlock x:Name="label"
VerticalAlignment="Center"
Foreground="#666"
Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</Border>
<DataTemplate.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Path=IsSelected, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListBoxItem}}}" Value="True" />
<Condition Binding="{Binding Path=(Selector.IsSelectionActive), RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ListBoxItem}}}" Value="True" />
</MultiDataTrigger.Conditions>
<MultiDataTrigger.Setters>
<Setter TargetName="iconPath" Property="Foreground" Value="White" />
<Setter TargetName="label" Property="Foreground" Value="White" />
</MultiDataTrigger.Setters>
</MultiDataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel x:Name="noRepositoriesMessage"
Margin="0,-70,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding NoRepositoriesFound, Mode=OneWay, Converter={ghfvs:BooleanToVisibilityConverter}}">
<TextBlock HorizontalAlignment="Center"
Style="{DynamicResource GitHubH2TextBlock}"
Text="{x:Static prop:Resources.noRepositoriesMessageText}" />
</StackPanel>
</Grid>
</Border>
</DockPanel>
</local:GenericRepositoryCloneView>

Просмотреть файл

@ -1,126 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.Composition;
using System.Linq;
using System.Reactive.Linq;
using System.Windows;
using System.Windows.Data;
using System.Windows.Input;
using GitHub.Exports;
using GitHub.Extensions;
using GitHub.Models;
using GitHub.UI;
using GitHub.ViewModels.Dialog;
using ReactiveUI;
namespace GitHub.VisualStudio.Views.Dialog
{
public class GenericRepositoryCloneView : ViewBase<IRepositoryCloneViewModel, RepositoryCloneView>
{}
/// <summary>
/// Interaction logic for CloneRepoControl.xaml
/// </summary>
[ExportViewFor(typeof(IRepositoryCloneViewModel))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public partial class RepositoryCloneView : GenericRepositoryCloneView
{
readonly Dictionary<string, RepositoryGroup> groups = new Dictionary<string, RepositoryGroup>();
static readonly DependencyPropertyKey RepositoriesViewPropertyKey =
DependencyProperty.RegisterReadOnly(
nameof(RepositoriesView),
typeof(ICollectionView),
typeof(RepositoryCloneView),
new PropertyMetadata(null));
public static readonly DependencyProperty RepositoriesViewProperty = RepositoriesViewPropertyKey.DependencyProperty;
public RepositoryCloneView()
{
InitializeComponent();
this.WhenActivated(d =>
{
//d(repositoryList.Events().MouseDoubleClick.InvokeCommand(this, x => x.ViewModel.CloneCommand));
});
IsVisibleChanged += (s, e) =>
{
if (IsVisible)
this.TryMoveFocus(FocusNavigationDirection.First).Subscribe();
};
this.WhenAnyValue(x => x.ViewModel.Repositories, CreateRepositoryListCollectionView).Subscribe(x => RepositoriesView = x);
}
public ICollectionView RepositoriesView
{
get { return (ICollectionView)GetValue(RepositoriesViewProperty); }
private set { SetValue(RepositoriesViewPropertyKey, value); }
}
ListCollectionView CreateRepositoryListCollectionView(IEnumerable<IRemoteRepositoryModel> repositories)
{
if (repositories == null)
return null;
var view = new ListCollectionView((IList)repositories);
view.GroupDescriptions.Add(new RepositoryGroupDescription(this));
return view;
}
class RepositoryGroupDescription : GroupDescription
{
readonly RepositoryCloneView owner;
public RepositoryGroupDescription(RepositoryCloneView owner)
{
Guard.ArgumentNotNull(owner, nameof(owner));
this.owner = owner;
}
public override object GroupNameFromItem(object item, int level, System.Globalization.CultureInfo culture)
{
var repo = item as IRemoteRepositoryModel;
var name = repo?.Owner ?? string.Empty;
RepositoryGroup group;
if (!owner.groups.TryGetValue(name, out group))
{
group = new RepositoryGroup(name, owner.groups.Count == 0);
if (owner.groups.Count == 1)
owner.groups.Values.First().IsExpanded = false;
owner.groups.Add(name, group);
}
return group;
}
}
class RepositoryGroup : ReactiveObject
{
bool isExpanded;
public RepositoryGroup(string header, bool isExpanded)
{
Guard.ArgumentNotEmptyString(header, nameof(header));
Header = header;
this.isExpanded = isExpanded;
}
public string Header { get; }
public bool IsExpanded
{
get { return isExpanded; }
set { this.RaiseAndSetIfChanged(ref isExpanded, value); }
}
}
}
}

Просмотреть файл

@ -41,7 +41,6 @@
<DockPanel>
<ghfvs:InfoPanel Name="infoPanel"
DockPanel.Dock="Top"
MessageType="{Binding MessageType}"
Message="{Binding Message}"
VerticalAlignment="Top"/>
<StackPanel DockPanel.Dock="Top"

Просмотреть файл

@ -33,9 +33,9 @@ namespace GitHub.VisualStudio.Views.GitHubPane
.Subscribe(n =>
{
if (n.Type == Notification.NotificationType.Error || n.Type == Notification.NotificationType.Warning)
infoPanel.MessageType = MessageType.Warning;
infoPanel.Icon = Octicon.alert;
else
infoPanel.MessageType = MessageType.Information;
infoPanel.Icon = Octicon.info;
infoPanel.Message = n.Message;
}));
});

Просмотреть файл

@ -27,7 +27,7 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
await target.Login(host, client, "foo", "bar");
await keychain.Received().Save("foo", "123abc", host);
@ -45,10 +45,10 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
var result = await target.Login(host, client, "foo", "bar");
Assert.That(user, Is.SameAs(result));
Assert.That(user, Is.SameAs(result.User));
}
[Test]
@ -69,7 +69,7 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
var result = await target.Login(host, client, "foo", "bar");
await client.Authorization.Received(2).GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>());
@ -93,7 +93,7 @@ public class LoginManagerTests
var oauthListener = Substitute.For<IOAuthCallbackListener>();
tfa.Value.HandleTwoFactorException(exception).Returns(new TwoFactorChallengeResult("123456"));
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
await target.Login(host, client, "foo", "bar");
await client.Authorization.Received().GetOrCreateApplicationAuthentication(
@ -123,7 +123,7 @@ public class LoginManagerTests
new TwoFactorChallengeResult("111111"),
new TwoFactorChallengeResult("123456"));
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
await target.Login(host, client, "foo", "bar");
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
@ -159,7 +159,7 @@ public class LoginManagerTests
new TwoFactorChallengeResult("111111"),
new TwoFactorChallengeResult("123456"));
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
Assert.ThrowsAsync<LoginAttemptsExceededException>(async () => await target.Login(host, client, "foo", "bar"));
await client.Authorization.Received(1).GetOrCreateApplicationAuthentication(
@ -190,7 +190,7 @@ public class LoginManagerTests
TwoFactorChallengeResult.RequestResendCode,
new TwoFactorChallengeResult("123456"));
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
await target.Login(host, client, "foo", "bar");
await client.Authorization.Received(2).GetOrCreateApplicationAuthentication("id", "secret", Arg.Any<NewAuthorization>());
@ -213,7 +213,7 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
await target.Login(enterprise, client, "foo", "bar");
await keychain.Received().Save("foo", "bar", enterprise);
@ -232,7 +232,7 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
Assert.ThrowsAsync<AuthorizationException>(async () => await target.Login(enterprise, client, "foo", "bar"));
await keychain.Received().Delete(enterprise);
@ -251,7 +251,7 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
Assert.ThrowsAsync<InvalidOperationException>(async () => await target.Login(host, client, "foo", "bar"));
@ -276,7 +276,7 @@ public class LoginManagerTests
var oauthListener = Substitute.For<IOAuthCallbackListener>();
tfa.Value.HandleTwoFactorException(exception).Returns(new TwoFactorChallengeResult("123456"));
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
Assert.ThrowsAsync<InvalidOperationException>(async () => await target.Login(host, client, "foo", "bar"));
await keychain.Received().Delete(host);
@ -293,7 +293,7 @@ public class LoginManagerTests
var tfa = new Lazy<ITwoFactorChallengeHandler>(() => Substitute.For<ITwoFactorChallengeHandler>());
var oauthListener = Substitute.For<IOAuthCallbackListener>();
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes);
var target = new LoginManager(keychain, tfa, oauthListener, "id", "secret", scopes, scopes);
Assert.ThrowsAsync<IncorrectScopesException>(() => target.Login(host, client, "foo", "bar"));
}
@ -311,39 +311,4 @@ public class LoginManagerTests
return result;
}
}
public class TheScopesMatchMethod
{
[Test]
public void ReturnsFalseWhenMissingScopes()
{
var received = new[] { "user", "repo", "write:public_key" };
Assert.False(LoginManager.ScopesMatch(scopes, received));
}
[Test]
public void ReturnsTrueWhenScopesEqual()
{
var received = new[] { "user", "repo", "gist", "write:public_key" };
Assert.True(LoginManager.ScopesMatch(scopes, received));
}
[Test]
public void ReturnsTrueWhenExtraScopesReturned()
{
var received = new[] { "user", "repo", "gist", "foo", "write:public_key" };
Assert.True(LoginManager.ScopesMatch(scopes, received));
}
[Test]
public void ReturnsTrueWhenAdminScopeReturnedInsteadOfWrite()
{
var received = new[] { "user", "repo", "gist", "foo", "admin:public_key" };
Assert.True(LoginManager.ScopesMatch(scopes, received));
}
}
}

Просмотреть файл

@ -7,6 +7,7 @@ using GitHub.Services;
using System.Linq.Expressions;
using System;
using GitHub.Models;
using GitHub.Api;
public class RepositoryCloneServiceTests
{
@ -32,8 +33,9 @@ public class RepositoryCloneServiceTests
var serviceProvider = Substitutes.ServiceProvider;
var operatingSystem = serviceProvider.GetOperatingSystem();
var vsGitServices = serviceProvider.GetVSGitServices();
var graphqlFactory = Substitute.For<IGraphQLClientFactory>();
var usageTracker = Substitute.For<IUsageTracker>();
var cloneService = new RepositoryCloneService(operatingSystem, vsGitServices, usageTracker);
var cloneService = new RepositoryCloneService(operatingSystem, vsGitServices, graphqlFactory, usageTracker);
await cloneService.CloneRepository("https://github.com/foo/bar", "bar", @"c:\dev");
var model = UsageModel.Create(Guid.NewGuid());

Просмотреть файл

@ -9,6 +9,7 @@ using System;
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using GitHub.Factories;
using GitHub.Api;
namespace UnitTests
{
@ -110,7 +111,7 @@ namespace UnitTests
var os = OperatingSystem;
var vsgit = IVSGitServices;
var clone = cloneService ?? new RepositoryCloneService(os, vsgit, Substitute.For<IUsageTracker>());
var clone = cloneService ?? new RepositoryCloneService(os, vsgit, Substitute.For<IGraphQLClientFactory>(), Substitute.For<IUsageTracker>());
var create = creationService ?? new RepositoryCreationService(clone);
avatarProvider = avatarProvider ?? Substitute.For<IAvatarProvider>();
//ret.GetService(typeof(IGitRepositoriesExt)).Returns(IGitRepositoriesExt);

Просмотреть файл

@ -0,0 +1,268 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Models;
using GitHub.Primitives;
using GitHub.Services;
using GitHub.ViewModels.Dialog.Clone;
using NSubstitute;
using NUnit.Framework;
using Rothko;
namespace GitHub.App.UnitTests.ViewModels.Dialog.Clone
{
public class RepositoryCloneViewModelTests
{
[Test]
public async Task GitHubPage_Is_Initialized()
{
var cm = CreateConnectionManager("https://github.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(null);
target.GitHubTab.Received(1).Initialize(cm.Connections[0]);
target.EnterpriseTab.DidNotReceiveWithAnyArgs().Initialize(null);
}
[Test]
public async Task EnterprisePage_Is_Initialized()
{
var cm = CreateConnectionManager("https://enterprise.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(null);
target.GitHubTab.DidNotReceiveWithAnyArgs().Initialize(null);
target.EnterpriseTab.Received(1).Initialize(cm.Connections[0]);
}
[Test]
public async Task GitHub_And_Enterprise_Pages_Are_Initialized()
{
var cm = CreateConnectionManager("https://github.com", "https://enterprise.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(null);
target.GitHubTab.Received(1).Initialize(cm.Connections[0]);
target.EnterpriseTab.Received(1).Initialize(cm.Connections[1]);
}
[Test]
public async Task GitHubPage_Is_Loaded()
{
var cm = CreateConnectionManager("https://github.com", "https://enterprise.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(null);
await target.GitHubTab.Received(1).Activate();
await target.EnterpriseTab.DidNotReceive().Activate();
}
[Test]
public async Task Enterprise_Is_Loaded()
{
var cm = CreateConnectionManager("https://github.com", "https://enterprise.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(cm.Connections[1]);
await target.GitHubTab.DidNotReceive().Activate();
await target.EnterpriseTab.Received(1).Activate();
}
[Test]
public async Task Switching_To_GitHubPage_Loads_It()
{
var cm = CreateConnectionManager("https://github.com", "https://enterprise.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(cm.Connections[1]);
await target.GitHubTab.DidNotReceive().Activate();
target.SelectedTabIndex = 0;
await target.GitHubTab.Received(1).Activate();
}
[Test]
public async Task Switching_To_EnterprisePage_Loads_It()
{
var cm = CreateConnectionManager("https://github.com", "https://enterprise.com");
var target = CreateTarget(connectionManager: cm);
await target.InitializeAsync(cm.Connections[0]);
await target.EnterpriseTab.DidNotReceive().Activate();
target.SelectedTabIndex = 1;
await target.EnterpriseTab.Received(1).Activate();
}
[Test]
public async Task Path_Is_Initialized()
{
var target = CreateTarget();
Assert.That(target.Path, Is.EqualTo("d:\\efault\\path"));
}
[Test]
public async Task Repository_Name_Is_Appended_To_Base_Path()
{
var target = CreateTarget();
var repository = Substitute.For<IRepositoryModel>();
repository.Name.Returns("repo");
SetRepository(target.GitHubTab, repository);
Assert.That(target.Path, Is.EqualTo("d:\\efault\\path\\repo"));
}
[Test]
public async Task PathError_Is_Not_Set_When_No_Repository_Selected()
{
var target = CreateTarget();
target.Path = "d:\\exists";
Assert.That(target.PathError, Is.Null);
}
[Test]
public async Task PathError_Is_Set_For_Existing_Destination()
{
var target = CreateTarget();
var repository = Substitute.For<IRepositoryModel>();
repository.Name.Returns("repo");
SetRepository(target.GitHubTab, repository);
target.Path = "d:\\exists";
Assert.That(target.PathError, Is.EqualTo(Resources.DestinationAlreadyExists));
}
[Test]
public async Task Repository_Name_Replaces_Last_Part_Of_Non_Base_Path()
{
var target = CreateTarget();
var repository = Substitute.For<IRepositoryModel>();
target.Path = "d:\\efault\\foo";
repository.Name.Returns("repo");
SetRepository(target.GitHubTab, repository);
Assert.That(target.Path, Is.EqualTo("d:\\efault\\repo"));
}
[Test]
public async Task Clone_Is_Initially_Disabled()
{
var target = CreateTarget();
await target.InitializeAsync(null);
Assert.That(target.Clone.CanExecute(null), Is.False);
}
[Test]
public async Task Clone_Is_Enabled_When_Repository_Selected()
{
var target = CreateTarget();
await target.InitializeAsync(null);
SetRepository(target.GitHubTab, Substitute.For<IRepositoryModel>());
Assert.That(target.Clone.CanExecute(null), Is.True);
}
[Test]
public async Task Clone_Is_Disabled_When_Has_PathError()
{
var target = CreateTarget();
await target.InitializeAsync(null);
SetRepository(target.GitHubTab, Substitute.For<IRepositoryModel>());
Assert.That(target.Clone.CanExecute(null), Is.True);
target.Path = "d:\\exists";
Assert.That(target.Clone.CanExecute(null), Is.False);
}
static void SetRepository(IRepositoryCloneTabViewModel vm, IRepositoryModel repository)
{
vm.Repository.Returns(repository);
vm.PropertyChanged += Raise.Event<PropertyChangedEventHandler>(
vm,
new PropertyChangedEventArgs(nameof(vm.Repository)));
}
static IConnectionManager CreateConnectionManager(params string[] addresses)
{
var result = Substitute.For<IConnectionManager>();
var connections = new ObservableCollectionEx<IConnection>();
result.Connections.Returns(connections);
result.GetLoadedConnections().Returns(connections);
result.GetConnection(null).ReturnsForAnyArgs(default(IConnection));
foreach (var address in addresses)
{
var connection = Substitute.For<IConnection>();
var hostAddress = HostAddress.Create(address);
connection.HostAddress.Returns(hostAddress);
connection.IsLoggedIn.Returns(true);
connections.Add(connection);
result.GetConnection(hostAddress).Returns(connection);
}
return result;
}
static IRepositorySelectViewModel CreateSelectViewModel()
{
var result = Substitute.For<IRepositorySelectViewModel>();
result.Repository.Returns((IRepositoryModel)null);
result.WhenForAnyArgs(x => x.Initialize(null)).Do(_ => result.IsEnabled.Returns(true));
return result;
}
static IRepositoryCloneService CreateRepositoryCloneService()
{
var result = Substitute.For<IRepositoryCloneService>();
result.DefaultClonePath.Returns("d:\\efault\\path");
result.DestinationExists("d:\\exists").Returns(true);
return result;
}
static RepositoryCloneViewModel CreateTarget(
IOperatingSystem os = null,
IConnectionManager connectionManager = null,
IRepositoryCloneService service = null,
IRepositorySelectViewModel gitHubTab = null,
IRepositorySelectViewModel enterpriseTab = null,
IRepositoryUrlViewModel urlTab = null)
{
os = os ?? Substitute.For<IOperatingSystem>();
connectionManager = connectionManager ?? CreateConnectionManager("https://github.com");
service = service ?? CreateRepositoryCloneService();
gitHubTab = gitHubTab ?? CreateSelectViewModel();
enterpriseTab = enterpriseTab ?? CreateSelectViewModel();
urlTab = urlTab ?? Substitute.For<IRepositoryUrlViewModel>();
return new RepositoryCloneViewModel(
os,
connectionManager,
service,
gitHubTab,
enterpriseTab,
urlTab);
}
}
}

Просмотреть файл

@ -52,7 +52,7 @@ namespace UnitTests.GitHub.App.ViewModels.Dialog
var target = CreateTarget();
var content = Substitute.For<ITestViewModel>();
await target.StartWithConnection(content);
target.StartWithConnection(content).Forget();
Assert.That(target.Content, Is.InstanceOf<ILoginViewModel>());
}
@ -64,7 +64,7 @@ namespace UnitTests.GitHub.App.ViewModels.Dialog
var target = CreateTarget(connectionManager);
var content = Substitute.For<ITestViewModel>();
await target.StartWithConnection(content);
target.StartWithConnection(content).Forget();
Assert.That(content, Is.SameAs(target.Content));
await content.Received(1).InitializeAsync(connectionManager.Connections[0]);
@ -75,13 +75,13 @@ namespace UnitTests.GitHub.App.ViewModels.Dialog
{
var target = CreateTarget();
var content = Substitute.For<ITestViewModel>();
await target.StartWithConnection(content);
var task = target.StartWithConnection(content);
var login = (ILoginViewModel)target.Content;
var connection = Substitute.For<IConnection>();
((ISubject<object>)login.Done).OnNext(connection);
await task;
Assert.That(content, Is.SameAs(target.Content));
await content.Received(1).InitializeAsync(connection);
}
@ -94,11 +94,12 @@ namespace UnitTests.GitHub.App.ViewModels.Dialog
var closed = false;
target.Done.Subscribe(_ => closed = true);
await target.StartWithConnection(content);
var task = target.StartWithConnection(content);
var login = (ILoginViewModel)target.Content;
((ISubject<object>)login.Done).OnNext(null);
await task;
Assert.True(closed);
}
}

Просмотреть файл

@ -1,400 +0,0 @@
using System;
using System.Reactive;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Threading.Tasks;
using GitHub.Models;
using GitHub.Services;
using GitHub.Validation;
using NSubstitute;
using Rothko;
using NUnit.Framework;
using GitHub.Collections;
using NSubstitute.Core;
using GitHub.Factories;
using GitHub.Primitives;
using GitHub.ViewModels.Dialog;
using System.Diagnostics;
public class RepositoryCloneViewModelTests
{
const int Timeout = 2000;
static RepositoryCloneViewModel GetVM(IModelService modelService, IRepositoryCloneService cloneService, IOperatingSystem os)
{
var connection = Substitute.For<IConnection>();
connection.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
var modelServiceFactory = Substitute.For<IModelServiceFactory>();
modelServiceFactory.CreateAsync(connection).Returns(modelService);
var vm = new RepositoryCloneViewModel(
modelServiceFactory,
cloneService,
os);
vm.InitializeAsync(connection).Wait();
return vm;
}
static ITrackingCollection<IRemoteRepositoryModel> SetupRepositories(
CallInfo callInfo,
IObservable<IRemoteRepositoryModel> repositories)
{
var collection = callInfo.Arg<ITrackingCollection<IRemoteRepositoryModel>>();
collection.Listen(repositories);
return collection;
}
static IRemoteRepositoryModel CreateMockRepositoryModel()
{
var result = Substitute.For<IRemoteRepositoryModel>();
result.Equals(result).Returns(true);
return result;
}
public class TheLoadRepositoriesCommand : TestBaseClass
{
[Test]
public async Task LoadsRepositoriesAsync()
{
var repos = new IRemoteRepositoryModel[]
{
CreateMockRepositoryModel(),
CreateMockRepositoryModel(),
CreateMockRepositoryModel(),
};
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repos.ToObservable()));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
var col = (ITrackingCollection<IRemoteRepositoryModel>)vm.Repositories;
await col.OriginalCompleted.Timeout(TimeSpan.FromMilliseconds(Timeout));
Assert.That(3, Is.EqualTo(vm.Repositories.Count));
}
}
public class TheIsBusyProperty : TestBaseClass
{
[Test]
public async Task StartsTrueBecomesFalseWhenCompletedAsync()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
var col = (ITrackingCollection<IRemoteRepositoryModel>)vm.Repositories;
Assert.True(vm.IsBusy);
var done = new ReplaySubject<Unit>();
done.OnNext(Unit.Default);
done.Subscribe();
col.Subscribe(t => done?.OnCompleted(), () => { });
repoSubject.OnNext(Substitute.For<IRemoteRepositoryModel>());
repoSubject.OnNext(Substitute.For<IRemoteRepositoryModel>());
await done.Timeout(TimeSpan.FromMilliseconds(Timeout));
done = null;
Assert.True(vm.IsBusy);
repoSubject.OnCompleted();
await col.OriginalCompleted.Timeout(TimeSpan.FromMilliseconds(Timeout));
// we need to wait slightly because the subscription OnComplete in the model
// runs right after the above await finishes, which means the assert
// gets checked before the flag is set
await Task.Delay(100);
Assert.False(vm.IsBusy);
}
[Test]
public void IsFalseWhenLoadingReposFailsImmediately()
{
var repoSubject = Observable.Throw<IRemoteRepositoryModel>(new InvalidOperationException("Doh!"));
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
Assert.True(vm.LoadingFailed);
Assert.False(vm.IsBusy);
}
}
public class TheNoRepositoriesFoundProperty : TestBaseClass
{
[Test]
public void IsTrueInitially()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var connection = Substitute.For<IConnection>();
connection.HostAddress.Returns(HostAddress.GitHubDotComHostAddress);
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var modelServiceFactory = Substitute.For<IModelServiceFactory>();
modelServiceFactory.CreateAsync(connection).Returns(modelService);
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = new RepositoryCloneViewModel(
modelServiceFactory,
cloneService,
Substitute.For<IOperatingSystem>());
Assert.False(vm.LoadingFailed);
Assert.True(vm.NoRepositoriesFound);
}
[Test]
public async Task IsFalseWhenLoadingAndCompletedWithRepositoryAsync()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
repoSubject.OnNext(Substitute.For<IRemoteRepositoryModel>());
Assert.False(vm.NoRepositoriesFound);
repoSubject.OnCompleted();
var col = (ITrackingCollection<IRemoteRepositoryModel>)vm.Repositories;
await col.OriginalCompleted.Timeout(TimeSpan.FromMilliseconds(Timeout));
//Assert.Single(vm.Repositories);
Assert.False(vm.NoRepositoriesFound);
}
[Test]
public void IsFalseWhenFailed()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
repoSubject.OnError(new InvalidOperationException());
Assert.False(vm.NoRepositoriesFound);
}
[Test]
public async Task IsTrueWhenLoadingCompleteNotFailedAndNoRepositoriesAsync()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
repoSubject.OnCompleted();
// we need to delay slightly because the subscribers listening for OnComplete
// need to run before the assert is checked
await Task.Delay(100);
Assert.True(vm.NoRepositoriesFound);
}
}
public class TheFilterTextEnabledProperty : TestBaseClass
{
[Test]
public void IsTrueInitially()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
Assert.False(vm.LoadingFailed);
Assert.True(vm.FilterTextIsEnabled);
}
[Test]
public void IsFalseIfLoadingReposFails()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
Assert.False(vm.LoadingFailed);
repoSubject.OnError(new InvalidOperationException("Doh!"));
Assert.True(vm.LoadingFailed);
Assert.False(vm.FilterTextIsEnabled);
repoSubject.OnCompleted();
}
[Test]
public async Task IsFalseWhenLoadingCompleteNotFailedAndNoRepositoriesAsync()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
repoSubject.OnCompleted();
// we need to delay slightly because the subscribers listening for OnComplete
// need to run before the assert is checked
await Task.Delay(100);
Assert.False(vm.FilterTextIsEnabled);
}
}
public class TheLoadingFailedProperty : TestBaseClass
{
[Test]
public void IsTrueIfLoadingReposFails()
{
var repoSubject = new Subject<IRemoteRepositoryModel>();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, repoSubject));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
Assert.False(vm.LoadingFailed);
repoSubject.OnError(new InvalidOperationException("Doh!"));
Assert.True(vm.LoadingFailed);
Assert.False(vm.IsBusy);
repoSubject.OnCompleted();
}
}
public class TheBaseRepositoryPathValidator
{
[Test]
public void IsInvalidWhenDestinationRepositoryExists()
{
var repo = Substitute.For<IRemoteRepositoryModel>();
repo.Id.Returns(1);
repo.Name.Returns("bar");
var data = new[] { repo }.ToObservable();
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, data));
var cloneService = Substitute.For<IRepositoryCloneService>();
var os = Substitute.For<IOperatingSystem>();
var directories = Substitute.For<IDirectoryFacade>();
os.Directory.Returns(directories);
directories.Exists(@"c:\foo\bar").Returns(true);
var vm = GetVM(
modelService,
cloneService,
os);
vm.BaseRepositoryPath = @"c:\foo";
vm.SelectedRepository = repo;
Assert.That(ValidationStatus.Invalid, Is.EqualTo(vm.BaseRepositoryPathValidator.ValidationResult.Status));
}
}
public class TheCloneCommand : TestBaseClass
{
[Test]
public void IsEnabledWhenRepositorySelectedAndPathValid()
{
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, Observable.Empty<IRemoteRepositoryModel>()));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
Assert.False(vm.CloneCommand.CanExecute(null));
vm.BaseRepositoryPath = @"c:\fake\path";
vm.SelectedRepository = Substitute.For<IRemoteRepositoryModel>();
Assert.True(vm.CloneCommand.CanExecute(null));
}
[Test]
public void IsNotEnabledWhenPathIsNotValid()
{
var modelService = Substitute.For<IModelService>();
modelService.GetRepositories(Arg.Any<ITrackingCollection<IRemoteRepositoryModel>>())
.Returns(x => SetupRepositories(x, Observable.Empty<IRemoteRepositoryModel>()));
var cloneService = Substitute.For<IRepositoryCloneService>();
var vm = GetVM(
modelService,
cloneService,
Substitute.For<IOperatingSystem>());
vm.BaseRepositoryPath = @"c:|fake\path";
Assert.False(vm.CloneCommand.CanExecute(null));
vm.SelectedRepository = Substitute.For<IRemoteRepositoryModel>();
Assert.False(vm.CloneCommand.CanExecute(null));
}
}
}

Просмотреть файл

@ -0,0 +1,45 @@
using System;
using GitHub.Models;
using NUnit.Framework;
namespace GitHub.Exports.UnitTests
{
public class ScopesCollectionTests
{
[Test]
public void Matches_Returns_False_When_Missing_Scopes()
{
var required = new[] { "user", "repo", "gist", "write:public_key" };
var target = new ScopesCollection(new[] { "user", "repo", "write:public_key" });
Assert.False(target.Matches(required));
}
[Test]
public void Returns_True_When_Scopes_Equal()
{
var required = new[] { "user", "repo", "gist", "write:public_key" };
var target = new ScopesCollection(new[] { "user", "repo", "gist", "write:public_key" });
Assert.True(target.Matches(required));
}
[Test]
public void Returns_True_When_Extra_Scopes_Returned()
{
var required = new[] { "user", "repo", "gist", "write:public_key" };
var target = new ScopesCollection(new[] { "user", "repo", "gist", "foo", "write:public_key" });
Assert.True(target.Matches(required));
}
[Test]
public void Returns_True_When_Admin_Scope_Returned_Instead_Of_Write()
{
var required = new[] { "user", "repo", "gist", "write:public_key" };
var target = new ScopesCollection(new[] { "user", "repo", "gist", "foo", "admin:public_key" });
Assert.True(target.Matches(required));
}
}
}

Просмотреть файл

@ -261,12 +261,14 @@ public class ConnectionManagerTests
static ILoginManager CreateLoginManager()
{
var result = Substitute.For<ILoginManager>();
result.Login(null, null, null, null)
.ReturnsForAnyArgs(new LoginResult(new User(), new ScopesCollection(new[] { "scope1" })));
result.Login(HostAddress.Create("invalid.com"), Arg.Any<IGitHubClient>(), Arg.Any<string>(), Arg.Any<string>())
.Returns<User>(_ => { throw new AuthorizationException(); });
.Returns<LoginResult>(_ => { throw new AuthorizationException(); });
result.LoginFromCache(null, null)
.ReturnsForAnyArgs(new User());
.ReturnsForAnyArgs(new LoginResult(new User(), new ScopesCollection(new[] { "scope1" })));
result.LoginFromCache(HostAddress.Create("invalid.com"), Arg.Any<IGitHubClient>())
.Returns<User>(_ => { throw new AuthorizationException(); });
.Returns<LoginResult>(_ => { throw new AuthorizationException(); });
return result;
}
}

Просмотреть файл

@ -9,6 +9,7 @@ using System;
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using GitHub.Factories;
using GitHub.Api;
namespace UnitTests
{
@ -110,7 +111,7 @@ namespace UnitTests
var os = OperatingSystem;
var vsgit = IVSGitServices;
var clone = cloneService ?? new RepositoryCloneService(os, vsgit, Substitute.For<IUsageTracker>());
var clone = cloneService ?? new RepositoryCloneService(os, vsgit, Substitute.For<IGraphQLClientFactory>(), Substitute.For<IUsageTracker>());
var create = creationService ?? new RepositoryCreationService(clone);
avatarProvider = avatarProvider ?? Substitute.For<IAvatarProvider>();
//ret.GetService(typeof(IGitRepositoriesExt)).Returns(IGitRepositoriesExt);