From 0737c7386f5e121e77ae484e026544f78607018c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 19 Jul 2018 18:07:36 +0200 Subject: [PATCH 01/13] Initial reimplementation of Clone dialog. - Uses GraphQL - Has a page for cloning from URL --- src/GitHub.Api/ApiClientConfiguration.cs | 2 +- src/GitHub.App/Properties/AssemblyInfo.cs | 2 + src/GitHub.App/Resources.Designer.cs | 11 +- src/GitHub.App/Resources.resx | 5 +- .../Clone/RepositoryCloneViewModelDesigner.cs | 33 ++ .../Clone/SelectPageViewModelDesigner.cs | 51 +++ src/GitHub.App/SampleData/SampleViewModels.cs | 183 +------- src/GitHub.App/Services/DialogService.cs | 1 + .../Services/RepositoryCloneService.cs | 49 ++- .../Dialog/Clone/RepositoryCloneViewModel.cs | 144 +++++++ .../Dialog/Clone/RepositoryItemViewModel.cs | 27 ++ .../Dialog/Clone/RepositorySelectViewModel.cs | 144 +++++++ .../Dialog/Clone/RepositoryUrlViewModel.cs | 65 +++ .../Dialog/RepositoryCloneViewModel.cs | 280 ------------ .../Services/IRepositoryCloneService.cs | 15 +- .../Clone/IRepositoryCloneTabViewModel.cs | 33 ++ .../Dialog/Clone/IRepositoryCloneViewModel.cs | 50 +++ .../Dialog/Clone/IRepositoryItemViewModel.cs | 14 + .../Clone/IRepositorySelectViewModel.cs | 20 + .../Dialog/Clone/IRepositoryUrlViewModel.cs | 9 + .../Dialog/IRepositoryCloneViewModel.cs | 36 -- .../Models/CloneDialogResult.cs | 11 +- .../Models/OrganizationRepositoriesModel.cs | 11 + .../Models/RepositoryListItemModel.cs | 13 + src/GitHub.Exports/ViewModels/IInfoPanel.cs | 14 - src/GitHub.StartPage/StartPagePackage.cs | 7 +- .../Connect/GitHubConnectSection.cs | 2 +- .../Controls/LightModalViewTabControl.xaml | 4 +- .../NullOrWhitespaceToVisibilityConverter.cs | 31 ++ src/GitHub.UI/GitHub.UI.csproj | 1 + .../GitHub.VisualStudio.UI.csproj | 45 +- .../Properties/AssemblyInfo.cs | 2 + .../Themes/Generic.xaml | 6 + .../UI/Controls/InfoPanel.cs | 107 +++++ .../UI/Controls/InfoPanel.xaml | 130 +++--- .../UI/Controls/InfoPanel.xaml.cs | 114 ----- .../GitHub.VisualStudio.csproj | 21 +- src/GitHub.VisualStudio/Views/ContentView.cs | 1 + .../Dialog/Clone/RepositoryCloneView.xaml | 75 ++++ .../Dialog/Clone/RepositoryCloneView.xaml.cs | 17 + .../Views/Dialog/Clone/SelectPageView.xaml | 50 +++ .../Views/Dialog/Clone/SelectPageView.xaml.cs | 23 + .../Views/Dialog/LoginCredentialsView.xaml | 2 +- .../Views/Dialog/RepositoryCloneView.xaml | 327 -------------- .../Views/Dialog/RepositoryCloneView.xaml.cs | 126 ------ .../Views/GitHubPane/GitHubPaneView.xaml | 1 - .../Views/GitHubPane/GitHubPaneView.xaml.cs | 4 +- .../Services/RepositoryCloneServiceTests.cs | 4 +- test/GitHub.App.UnitTests/Substitutes.cs | 3 +- .../Clone/RepositoryCloneViewModelTests.cs | 264 ++++++++++++ .../Dialog/RepositoryCloneViewModelTests.cs | 400 ------------------ .../Substitutes.cs | 3 +- 52 files changed, 1409 insertions(+), 1584 deletions(-) create mode 100644 src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs create mode 100644 src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs create mode 100644 src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs create mode 100644 src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs create mode 100644 src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs create mode 100644 src/GitHub.App/ViewModels/Dialog/Clone/RepositoryUrlViewModel.cs delete mode 100644 src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryUrlViewModel.cs delete mode 100644 src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs create mode 100644 src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs create mode 100644 src/GitHub.Exports/Models/RepositoryListItemModel.cs delete mode 100644 src/GitHub.Exports/ViewModels/IInfoPanel.cs create mode 100644 src/GitHub.UI/Converters/NullOrWhitespaceToVisibilityConverter.cs create mode 100644 src/GitHub.VisualStudio.UI/Themes/Generic.xaml create mode 100644 src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs delete mode 100644 src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs create mode 100644 src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml create mode 100644 src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml.cs create mode 100644 src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml create mode 100644 src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml.cs delete mode 100644 src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml delete mode 100644 src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml.cs create mode 100644 test/GitHub.App.UnitTests/ViewModels/Dialog/Clone/RepositoryCloneViewModelTests.cs delete mode 100644 test/GitHub.App.UnitTests/ViewModels/Dialog/RepositoryCloneViewModelTests.cs diff --git a/src/GitHub.Api/ApiClientConfiguration.cs b/src/GitHub.Api/ApiClientConfiguration.cs index fc8cba3eb7..a3f7422c64 100644 --- a/src/GitHub.Api/ApiClientConfiguration.cs +++ b/src/GitHub.Api/ApiClientConfiguration.cs @@ -35,7 +35,7 @@ static ApiClientConfiguration() /// /// Gets the scopes required by the application. /// - public static IReadOnlyList RequiredScopes { get; } = new[] { "user", "repo", "gist", "write:public_key" }; + public static IReadOnlyList RequiredScopes { get; } = new[] { "user", "repo", "gist", "write:public_key", "read:org" }; /// /// Gets a note that will be stored with the OAUTH token. diff --git a/src/GitHub.App/Properties/AssemblyInfo.cs b/src/GitHub.App/Properties/AssemblyInfo.cs index 5c7470009c..20fe17f77f 100644 --- a/src/GitHub.App/Properties/AssemblyInfo.cs +++ b/src/GitHub.App/Properties/AssemblyInfo.cs @@ -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")] diff --git a/src/GitHub.App/Resources.Designer.cs b/src/GitHub.App/Resources.Designer.cs index db4dfecd99..f1e0b741a0 100644 --- a/src/GitHub.App/Resources.Designer.cs +++ b/src/GitHub.App/Resources.Designer.cs @@ -97,7 +97,7 @@ public static string ChangesRequested { } /// - /// Looks up a localized string similar to Clone a {0} Repository. + /// Looks up a localized string similar to Clone a Repository. /// public static string CloneTitle { get { @@ -162,6 +162,15 @@ public static string DefaultGistFileName { } } + /// + /// Looks up a localized string similar to The destination already exists.. + /// + public static string DestinationAlreadyExists { + get { + return ResourceManager.GetString("DestinationAlreadyExists", resourceCulture); + } + } + /// /// Looks up a localized string similar to Please enter an Enterprise URL. /// diff --git a/src/GitHub.App/Resources.resx b/src/GitHub.App/Resources.resx index 8d9d9aabc6..dcff2cb650 100644 --- a/src/GitHub.App/Resources.resx +++ b/src/GitHub.App/Resources.resx @@ -121,7 +121,7 @@ Select a containing folder for your new repository. - Clone a {0} Repository + Clone a Repository Could not connect to github.com @@ -321,4 +321,7 @@ https://git-scm.com/download/win Switch Origin + + The destination already exists. + \ No newline at end of file diff --git a/src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs b/src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs new file mode 100644 index 0000000000..67a8f1c921 --- /dev/null +++ b/src/GitHub.App/SampleData/Dialog/Clone/RepositoryCloneViewModelDesigner.cs @@ -0,0 +1,33 @@ +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 Done => null; + public IRepositorySelectViewModel GitHubTab { get; } + public IRepositorySelectViewModel EnterpriseTab { get; } + public IRepositoryUrlViewModel UrlTab { get; } + public ReactiveCommand Clone { get; } + + public Task InitializeAsync(IConnection connection) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs b/src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs new file mode 100644 index 0000000000..d33041d2ad --- /dev/null +++ b/src/GitHub.App/SampleData/Dialog/Clone/SelectPageViewModelDesigner.cs @@ -0,0 +1,51 @@ +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)).ToList(); + ItemsView = CollectionViewSource.GetDefaultView(Items); + } + + public Exception Error { get; set; } + public string Filter { get; set; } + public bool IsEnabled { get; set; } = true; + public bool IsLoading { get; set; } + public IReadOnlyList 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; + } + } +} diff --git a/src/GitHub.App/SampleData/SampleViewModels.cs b/src/GitHub.App/SampleData/SampleViewModels.cs index e73770d794..8234db0e0e 100644 --- a/src/GitHub.App/SampleData/SampleViewModels.cs +++ b/src/GitHub.App/SampleData/SampleViewModels.cs @@ -12,6 +12,7 @@ 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; @@ -253,186 +254,4 @@ public static IRemoteRepositoryModel Create(string name = null, string owner = n 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 - { - 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 CloneCommand - { - get; - private set; - } - - public IRepositoryModel SelectedRepository { get; set; } - - public ObservableCollection 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> 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 BaseRepositoryPathValidator - { - get; - private set; - } - - public IObservable 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(); - 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 Repositories - { - get; set; - } - - public void DoCreate() - { - } - - public void SignOut() - { - } - - public void Login() - { - } - - public bool OpenRepository() - { - return true; - } - - public IConnection SectionConnection { get; } - 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; - } } diff --git a/src/GitHub.App/Services/DialogService.cs b/src/GitHub.App/Services/DialogService.cs index 43ae5f98a2..7e6ccc065b 100644 --- a/src/GitHub.App/Services/DialogService.cs +++ b/src/GitHub.App/Services/DialogService.cs @@ -5,6 +5,7 @@ using GitHub.Factories; using GitHub.Models; using GitHub.ViewModels.Dialog; +using GitHub.ViewModels.Dialog.Clone; namespace GitHub.Services { diff --git a/src/GitHub.App/Services/RepositoryCloneService.cs b/src/GitHub.App/Services/RepositoryCloneService.cs index 8952585263..9b95dad939 100644 --- a/src/GitHub.App/Services/RepositoryCloneService.cs +++ b/src/GitHub.App/Services/RepositoryCloneService.cs @@ -1,14 +1,20 @@ 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.Helpers; using GitHub.Logging; +using GitHub.Models; +using GitHub.Primitives; using Microsoft.VisualStudio.Shell; -using Serilog; +using Octokit.GraphQL; using Rothko; -using GitHub.Helpers; +using Serilog; using Task = System.Threading.Tasks.Task; namespace GitHub.Services @@ -27,21 +33,50 @@ public class RepositoryCloneService : IRepositoryCloneService readonly IOperatingSystem operatingSystem; readonly string defaultClonePath; readonly IVSGitServices vsGitServices; + readonly IGraphQLClientFactory graphqlFactory; readonly IUsageTracker usageTracker; + ICompiledQuery> 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()); } + /// + public async Task> ReadViewerRepositories(HostAddress address) + { + if (readViewerRepositories == null) + { + readViewerRepositories = new Query() + .Viewer + .Organizations().AllPages().Select(org => new OrganizationAdapter + { + Repositories = org.Repositories(null, null, null, null, null, null, null, null, null).AllPages().Select(repo => new RepositoryListItemModel + { + IsFork = repo.IsFork, + IsPrivate = repo.IsPrivate, + Name = repo.Name, + Owner = repo.Owner.Login, + Url = new Uri(repo.Url), + }).ToList(), + }).Compile(); + } + + var graphql = await graphqlFactory.CreateConnection(address).ConfigureAwait(false); + var result = await graphql.Run(readViewerRepositories).ConfigureAwait(false); + return result.SelectMany(x => x.Repositories); + } + /// public async Task CloneRepository( string cloneUrl, @@ -73,6 +108,9 @@ public async Task CloneRepository( } } + /// + public bool DestinationExists(string path) => Directory.Exists(path) || File.Exists(path); + string GetLocalClonePathFromGitProvider(string fallbackPath) { var ret = vsGitServices.GetLocalClonePathFromGitProvider(); @@ -82,5 +120,10 @@ string GetLocalClonePathFromGitProvider(string fallbackPath) } public string DefaultClonePath { get { return defaultClonePath; } } + + class OrganizationAdapter + { + public IReadOnlyList Repositories { get; set; } + } } } diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs new file mode 100644 index 0000000000..0bb06dbd1c --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryCloneViewModel.cs @@ -0,0 +1,144 @@ +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 Serilog; + +namespace GitHub.ViewModels.Dialog.Clone +{ + [Export(typeof(IRepositoryCloneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class RepositoryCloneViewModel : ViewModelBase, IRepositoryCloneViewModel + { + static readonly ILogger log = LogManager.ForContext(); + readonly IConnectionManager connectionManager; + readonly IRepositoryCloneService service; + readonly IReadOnlyList tabs; + string path; + ObservableAsPropertyHelper pathError; + int selectedTabIndex; + + [ImportingConstructor] + public RepositoryCloneViewModel( + IConnectionManager connectionManager, + IRepositoryCloneService service, + IRepositorySelectViewModel gitHubTab, + IRepositorySelectViewModel enterpriseTab, + IRepositoryUrlViewModel urlTab) + { + 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); + + 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 Done => Clone; + + public ReactiveCommand 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 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; + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs new file mode 100644 index 0000000000..344c2e0dcd --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryItemViewModel.cs @@ -0,0 +1,27 @@ +using System; +using GitHub.Models; +using GitHub.UI; + +namespace GitHub.ViewModels.Dialog.Clone +{ + public class RepositoryItemViewModel : ViewModelBase, IRepositoryItemViewModel + { + public RepositoryItemViewModel(RepositoryListItemModel model) + { + Name = model.Name; + Owner = model.Owner; + Icon = model.IsPrivate + ? Octicon.@lock + : model.IsFork + ? Octicon.repo_forked + : Octicon.repo; + Url = model.Url; + } + + public string Caption => Owner + '/' + Name; + public string Name { get; } + public string Owner { get; } + public Octicon Icon { get; } + public Uri Url { get; } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs new file mode 100644 index 0000000000..f7f00f12f5 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositorySelectViewModel.cs @@ -0,0 +1,144 @@ +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(); + readonly IRepositoryCloneService service; + IConnection connection; + Exception error; + string filter; + bool isEnabled; + bool isLoading; + bool loadingStarted; + IReadOnlyList items; + ICollectionView itemsView; + ObservableAsPropertyHelper 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 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); + Items = results.Select(x => new RepositoryItemViewModel(x)).ToList(); + ItemsView = CollectionViewSource.GetDefaultView(Items); + ItemsView.Filter = FilterItem; + } + catch (Exception ex) + { + log.Error(ex, "Error reading repository list from {Address}", connection.HostAddress); + 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; + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryUrlViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryUrlViewModel.cs new file mode 100644 index 0000000000..cb4f4abb30 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/Clone/RepositoryUrlViewModel.cs @@ -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 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; + } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs b/src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs deleted file mode 100644 index f959091ee0..0000000000 --- a/src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs +++ /dev/null @@ -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(); - - readonly IModelServiceFactory modelServiceFactory; - readonly IOperatingSystem operatingSystem; - readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); - bool noRepositoriesFound; - readonly ObservableAsPropertyHelper 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(); - repositories.ProcessingDelay = TimeSpan.Zero; - repositories.Comparer = OrderedComparer.OrderBy(x => x.Owner).ThenBy(x => x.Name).Compare; - repositories.Filter = FilterRepository; - repositories.NewerComparer = OrderedComparer.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 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 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); - } - - /// - /// Gets the title for the dialog. - /// - public string Title { get; private set; } - - /// - /// Path to clone repositories into - /// - public string BaseRepositoryPath - { - get { return baseRepositoryPath; } - set { this.RaiseAndSetIfChanged(ref baseRepositoryPath, value); } - } - - /// - /// Signals that the user clicked the clone button. - /// - public IReactiveCommand CloneCommand { get; private set; } - - bool isBusy; - public bool IsBusy - { - get { return isBusy; } - private set { this.RaiseAndSetIfChanged(ref isBusy, value); } - } - - TrackingCollection repositories; - public ObservableCollection Repositories - { - get { return repositories; } - private set { this.RaiseAndSetIfChanged(ref repositories, (TrackingCollection)value); } - } - - IRepositoryModel selectedRepository; - /// - /// Selected repository to clone - /// - public IRepositoryModel SelectedRepository - { - get { return selectedRepository; } - set { this.RaiseAndSetIfChanged(ref selectedRepository, value); } - } - - readonly ObservableAsPropertyHelper filterTextIsEnabled; - /// - /// True if there are repositories (otherwise no point in filtering) - /// - public bool FilterTextIsEnabled { get { return filterTextIsEnabled.Value; } } - - string filterText; - /// - /// User text to filter the repositories list - /// - 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 BaseRepositoryPathValidator - { - get; - private set; - } - - public IObservable Done { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs b/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs index f3b7a8ec0c..b028c96a2f 100644 --- a/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs +++ b/src/GitHub.Exports.Reactive/Services/IRepositoryCloneService.cs @@ -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 @@ Task CloneRepository( string repositoryName, string repositoryPath, object progress = null); + + /// + /// Checks whether the specified destination path already exists. + /// + /// The destination path. + /// + /// true if a file or directory is already present at ; otherwise false. + /// + bool DestinationExists(string path); + + Task> ReadViewerRepositories(HostAddress address); } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs new file mode 100644 index 0000000000..6f6c7d6b43 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneTabViewModel.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.Dialog.Clone +{ + /// + /// Represents a tab in the repository clone dialog. + /// + public interface IRepositoryCloneTabViewModel : IViewModel + { + /// + /// Gets a value that indicates whether the tab is enabled. + /// + /// + /// A disabled tab will be hidden. + /// + bool IsEnabled { get; } + + /// + /// Gets the selected repository, or null if no repository has been selected. + /// + IRepositoryModel Repository { get; } + + /// + /// Activates the tab. + /// + /// + /// Will be called each time the tab is selected. + /// + Task Activate(); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs new file mode 100644 index 0000000000..5d64d4b321 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryCloneViewModel.cs @@ -0,0 +1,50 @@ +using System; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog.Clone +{ + /// + /// ViewModel for the the Clone Repository dialog + /// + public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel + { + /// + /// Gets the view model for the GitHub.com tab. + /// + IRepositorySelectViewModel GitHubTab { get; } + + /// + /// Gets the view model for the enterprise tab. + /// + IRepositorySelectViewModel EnterpriseTab { get; } + + /// + /// Gets the view model for the URL tab. + /// + IRepositoryUrlViewModel UrlTab { get; } + + /// + /// Gets the path to clone the repository to. + /// + string Path { get; set; } + + /// + /// Gets an error message that explains why is not valid. + /// + string PathError { get; } + + /// + /// Gets the index of the selected tab. + /// + /// + /// The tabs are: GitHubPage, EnterprisePage, UrlPage. + /// + int SelectedTabIndex { get; } + + /// + /// Gets the command executed when the user clicks "Clone". + /// + ReactiveCommand Clone { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs new file mode 100644 index 0000000000..6bd675ebee --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryItemViewModel.cs @@ -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; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs new file mode 100644 index 0000000000..e59837d972 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositorySelectViewModel.cs @@ -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 Items { get; } + ICollectionView ItemsView { get; } + IRepositoryItemViewModel SelectedItem { get; set; } + + void Initialize(IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryUrlViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryUrlViewModel.cs new file mode 100644 index 0000000000..57701e2948 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/Clone/IRepositoryUrlViewModel.cs @@ -0,0 +1,9 @@ +using System; + +namespace GitHub.ViewModels.Dialog.Clone +{ + public interface IRepositoryUrlViewModel : IRepositoryCloneTabViewModel + { + string Url { get; set; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs deleted file mode 100644 index f8742f0636..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using GitHub.Models; -using System.Collections.ObjectModel; - -namespace GitHub.ViewModels.Dialog -{ - /// - /// ViewModel for the the Clone Repository dialog - /// - public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel - { - /// - /// The list of repositories the current user may clone from the specified host. - /// - ObservableCollection Repositories { get; } - - bool FilterTextIsEnabled { get; } - - /// - /// If true, then we failed to load the repositories. - /// - bool LoadingFailed { get; } - - /// - /// Set to true if no repositories were found. - /// - bool NoRepositoriesFound { get; } - - /// - /// Set to true if a repository is selected. - /// - bool CanClone { get; } - - string FilterText { get; set; } - } -} diff --git a/src/GitHub.Exports/Models/CloneDialogResult.cs b/src/GitHub.Exports/Models/CloneDialogResult.cs index 05da3d10cd..7627397f87 100644 --- a/src/GitHub.Exports/Models/CloneDialogResult.cs +++ b/src/GitHub.Exports/Models/CloneDialogResult.cs @@ -1,5 +1,4 @@ using System; -using GitHub.Services; namespace GitHub.Models { @@ -11,18 +10,18 @@ public class CloneDialogResult /// /// Initializes a new instance of the class. /// - /// The selected base path for the clone. + /// The path to clone the repository to. /// The selected repository. - public CloneDialogResult(string basePath, IRepositoryModel repository) + public CloneDialogResult(string path, IRepositoryModel repository) { - BasePath = basePath; + Path = path; Repository = repository; } /// - /// Gets the filesystem path to which the user wants to clone. + /// Gets the path to clone the repository to. /// - public string BasePath { get; } + public string Path { get; } /// /// Gets the repository selected by the user. diff --git a/src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs b/src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs new file mode 100644 index 0000000000..dfa58be179 --- /dev/null +++ b/src/GitHub.Exports/Models/OrganizationRepositoriesModel.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + public class OrganizationRepositoriesModel + { + public ActorModel Organization { get; set; } + public IReadOnlyList Repositories { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/RepositoryListItemModel.cs b/src/GitHub.Exports/Models/RepositoryListItemModel.cs new file mode 100644 index 0000000000..757c0f18a6 --- /dev/null +++ b/src/GitHub.Exports/Models/RepositoryListItemModel.cs @@ -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; } + } +} diff --git a/src/GitHub.Exports/ViewModels/IInfoPanel.cs b/src/GitHub.Exports/ViewModels/IInfoPanel.cs deleted file mode 100644 index 4b8972df5a..0000000000 --- a/src/GitHub.Exports/ViewModels/IInfoPanel.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace GitHub.ViewModels -{ - public interface IInfoPanel - { - string Message { get; set; } - MessageType MessageType { get; set; } - } - - public enum MessageType - { - Information, - Warning - } -} \ No newline at end of file diff --git a/src/GitHub.StartPage/StartPagePackage.cs b/src/GitHub.StartPage/StartPagePackage.cs index 5635052a08..65f1a069f7 100644 --- a/src/GitHub.StartPage/StartPagePackage.cs +++ b/src/GitHub.StartPage/StartPagePackage.cs @@ -70,11 +70,10 @@ async Task RunAcquisition(IProgress download 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 @@ async Task ShowCloneDialog( await cloneService.CloneRepository( result.Repository.CloneUrl, result.Repository.Name, - result.BasePath, + result.Path, progress); usageTracker.IncrementCounter(x => x.NumberOfStartPageClones).Forget(); diff --git a/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs b/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs index d38f9365ea..fb5c230198 100644 --- a/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs +++ b/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs @@ -144,7 +144,7 @@ async Task DoClone() await cloneService.CloneRepository( result.Repository.CloneUrl, result.Repository.Name, - result.BasePath); + result.Path); usageTracker.IncrementCounter(x => x.NumberOfGitHubConnectSectionClones).Forget(); } diff --git a/src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml b/src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml index edfb7b1f7e..f0cfb7080a 100644 --- a/src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml +++ b/src/GitHub.UI/Assets/Controls/LightModalViewTabControl.xaml @@ -55,7 +55,7 @@ - + @@ -63,7 +63,7 @@ diff --git a/src/GitHub.UI/Converters/NullOrWhitespaceToVisibilityConverter.cs b/src/GitHub.UI/Converters/NullOrWhitespaceToVisibilityConverter.cs new file mode 100644 index 0000000000..16ec1b8b60 --- /dev/null +++ b/src/GitHub.UI/Converters/NullOrWhitespaceToVisibilityConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using System.Windows; + +namespace GitHub.UI +{ + [Localizability(LocalizationCategory.NeverLocalize)] + public sealed class NullOrWhitespaceToVisibilityConverter : ValueConverterMarkupExtension + { + 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(); + } + } +} diff --git a/src/GitHub.UI/GitHub.UI.csproj b/src/GitHub.UI/GitHub.UI.csproj index 041f14a08d..aecadad10d 100644 --- a/src/GitHub.UI/GitHub.UI.csproj +++ b/src/GitHub.UI/GitHub.UI.csproj @@ -97,6 +97,7 @@ + diff --git a/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj b/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj index 3e15e52e4e..056c33e7ca 100644 --- a/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj +++ b/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj @@ -5,6 +5,7 @@ Debug AnyCPU {D1DFBB0C-B570-4302-8F1E-2E3A19C41961} + {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} Library Properties GitHub.VisualStudio.UI @@ -15,6 +16,27 @@ ..\common\GitHubVS.ruleset true true + 15.0 + + + + + 14.0 + publish\ + true + Disk + false + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + false + true true @@ -96,9 +118,7 @@ AccountAvatar.xaml - - InfoPanel.xaml - + GitHubConnectContent.xaml @@ -200,13 +220,17 @@ MSBuild:Compile Designer + + MSBuild:Compile + Designer + MSBuild:Compile Designer - MSBuild:Compile Designer + MSBuild:Compile MSBuild:Compile @@ -258,7 +282,18 @@ Designer - + + + False + Microsoft .NET Framework 4.6.1 %28x86 and x64%29 + true + + + False + .NET Framework 3.5 SP1 + false + + diff --git a/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs b/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs index 0d5801ea70..e6d874be7e 100644 --- a/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs +++ b/src/GitHub.VisualStudio.UI/Properties/AssemblyInfo.cs @@ -1,12 +1,14 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Windows; using System.Windows.Markup; [assembly: AssemblyTitle("GitHub.VisualStudio.UI")] [assembly: AssemblyDescription("GitHub.VisualStudio.UI")] [assembly: Guid("d1dfbb0c-b570-4302-8f1e-2e3a19c41961")] +[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.UI")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.UI.Controls")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.VisualStudio.UI.Views")] diff --git a/src/GitHub.VisualStudio.UI/Themes/Generic.xaml b/src/GitHub.VisualStudio.UI/Themes/Generic.xaml new file mode 100644 index 0000000000..3203d021a4 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Themes/Generic.xaml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs new file mode 100644 index 0000000000..14e43e0215 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.cs @@ -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 +{ + /// + /// Displays informational or error message markdown in a banner. + /// + 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); + } + + /// + /// Gets or sets the message in markdown. + /// + public string Message + { + get => (string)GetValue(MessageProperty); + set => SetValue(MessageProperty, value); + } + + /// + /// Gets or sets the icon to display. + /// + 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(); + 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)); + } + } +} diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml index 3c19c8c5a9..3718d566c5 100644 --- a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml +++ b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml @@ -1,67 +1,65 @@ - + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs b/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs deleted file mode 100644 index 450e09c6b6..0000000000 --- a/src/GitHub.VisualStudio.UI/UI/Controls/InfoPanel.xaml.cs +++ /dev/null @@ -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(); - 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)); - } - } -} diff --git a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj index 3fd33217f7..0a94b8a5a1 100644 --- a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj +++ b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj @@ -384,6 +384,12 @@ + + RepositoryCloneView.xaml + + + SelectPageView.xaml + ForkRepositoryExecuteView.xaml @@ -402,9 +408,6 @@ Login2FaView.xaml - - RepositoryCloneView.xaml - RepositoryCreationView.xaml @@ -556,6 +559,14 @@ MSBuild:Compile GitHub.VisualStudio.UI + + MSBuild:Compile + Designer + + + Designer + MSBuild:Compile + Designer MSBuild:Compile @@ -580,10 +591,6 @@ MSBuild:Compile Designer - - MSBuild:Compile - Designer - MSBuild:Compile Designer diff --git a/src/GitHub.VisualStudio/Views/ContentView.cs b/src/GitHub.VisualStudio/Views/ContentView.cs index 95cb9b7d5a..2bdfbaff84 100644 --- a/src/GitHub.VisualStudio/Views/ContentView.cs +++ b/src/GitHub.VisualStudio/Views/ContentView.cs @@ -5,6 +5,7 @@ using GitHub.Exports; using GitHub.ViewModels; using GitHub.ViewModels.Dialog; +using GitHub.ViewModels.Dialog.Clone; namespace GitHub.VisualStudio.Views { diff --git a/src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml b/src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml new file mode 100644 index 0000000000..3783260606 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Repository URL or GitHub username and repository + + (hubot/cool-repo) + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml.cs new file mode 100644 index 0000000000..c975e832d0 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/Clone/RepositoryCloneView.xaml.cs @@ -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(); + } + } +} diff --git a/src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml b/src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml new file mode 100644 index 0000000000..58f5dcfbf8 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml.cs new file mode 100644 index 0000000000..32c8457df0 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/Clone/SelectPageView.xaml.cs @@ -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); + } + } +} diff --git a/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml b/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml index 7b4ad8418e..64618703c6 100644 --- a/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml +++ b/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml @@ -129,7 +129,7 @@ - + diff --git a/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml deleted file mode 100644 index db1977c283..0000000000 --- a/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml +++ /dev/null @@ -1,327 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -