diff --git a/docs/developer/dialog-views-with-connections.md b/docs/developer/dialog-views-with-connections.md new file mode 100644 index 0000000000..856b4ae67e --- /dev/null +++ b/docs/developer/dialog-views-with-connections.md @@ -0,0 +1,24 @@ +# Dialog Views with Connections + +Some dialog views need a connection to operate - if there is no connection, a login dialog should be shown: for example, clicking Create Gist without a connection will first prompt the user to log in. + +Achieving this is simple, first make your view model interface implement `IConnectionInitializedViewModel` and do any initialization that requires a connection in the `InitializeAsync` method in your view model: + +```csharp +public Task InitializeAsync(IConnection connection) +{ + // .. at this point, you're guaranteed to have a connection. +} +``` + +To show the dialog, call `IShowDialogService.ShowWithFirstConnection` instead of `Show`: + +```csharp +public async Task ShowExampleDialog() +{ + var viewModel = serviceProvider.ExportProvider.GetExportedValue(); + await showDialog.ShowWithFirstConnection(viewModel); +} +``` + +`ShowFirstConnection` first checks if there are any logged in connections. If there are, the first logged in connection will be passed to `InitializeAsync` and the view shown immediately. If there are no logged in connections, the login view will first be shown. Once the user has successfully logged in, the new connection will be passed to `InitalizeAsync` and the view shown. \ No newline at end of file diff --git a/docs/developer/how-viewmodels-are-turned-into-views.md b/docs/developer/how-viewmodels-are-turned-into-views.md new file mode 100644 index 0000000000..d5dbc14534 --- /dev/null +++ b/docs/developer/how-viewmodels-are-turned-into-views.md @@ -0,0 +1,86 @@ +# How ViewModels are Turned into Views + +We make use of the [MVVM pattern](https://msdn.microsoft.com/en-us/library/ff798384.aspx), in which application level code is not aware of the view level. MVVM takes advantage of the fact that `DataTemplate`s can be used to create views from view models. + +## DataTemplates + +[`DataTemplate`](https://docs.microsoft.com/en-us/dotnet/framework/wpf/data/data-templating-overview)s are a WPF feature that allow you to define the presentation of your data. Consider a simple view model: + +```csharp +public class ViewModel +{ + public string Greeting => "Hello World!"; +} +``` + +And a window: + +```csharp +public class MainWindow : Window +{ + public MainWindow() + { + DataContext = new ViewModel(); + InitializeComponent(); + } +} +``` + +```xml + + + +``` + +Here we're binding the `Content` of the `Window` to the `Window.DataContext`, which we're setting in the constructor to be an instance of `ViewModel`. + +One can choose to display the `ViewModel` instance in any way we want by using a `DataTemplate`: + +```xml + + + + + + + + + + + + +``` + +This is the basis for converting view models to views. + +## ViewLocator + +There are currently two top-level controls for our UI: + +- [GitHubDialogWindow](../src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml) for the dialog which shows the login, clone, etc views +- [GitHubPaneView](../src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml) for the GitHub pane + +In the resources for each of these top-level controls we define a `DataTemplate` like so: + +```xml + + + + +``` + +The `DataTemplate.DataType` here applies the template to all classes inherited from [`GitHub.ViewModels.ViewModelBase`](../src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs) [1]. The template defines a single `ContentControl` whose contents are created by a `ViewLocator`. + +The [`ViewLocator`](../src/GitHub.VisualStudio/Views/ViewLocator.cs) class is an `IValueConverter` which then creates an instance of the appropriate view for the view model using MEF. + +And thus a view model becomes a view. + +[1]: it would be nice to make it apply to all classes that inherit `IViewModel` but unfortunately WPF's `DataTemplate`s don't work with interfaces. \ No newline at end of file diff --git a/docs/developer/implementing-a-dialog-view.md b/docs/developer/implementing-a-dialog-view.md new file mode 100644 index 0000000000..332ddd4aa9 --- /dev/null +++ b/docs/developer/implementing-a-dialog-view.md @@ -0,0 +1,113 @@ +# Implementing a Dialog View + +GitHub for Visual Studio has a common dialog which is used to show the login, clone, create repository etc. operations. To add a new view to the dialog and show the dialog with this view, you need to do the following: + +## Create a View Model and Interface + +- Create an interface for the view model that implements `IDialogContentViewModel` in `GitHub.Exports.Reactive\ViewModels\Dialog` +- Create a view model that inherits from `NewViewModelBase` and implements the interface in `GitHub.App\ViewModels\Dialog` +- Export the view model with the interface as the contract and add a `[PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +A minimal example that just exposes a command that will dismiss the dialog: + +```csharp +using System; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + public interface IExampleDialogViewModel : IDialogContentViewModel + { + ReactiveCommand Dismiss { get; } + } +} +``` + +```csharp +using System; +using System.ComponentModel.Composition; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IExampleDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ExampleDialogViewModel : ViewModelBase, IExampleDialogViewModel + { + [ImportingConstructor] + public ExampleDialogViewModel() + { + Dismiss = ReactiveCommand.Create(); + } + + public string Title => "Example Dialog"; + + public ReactiveCommand Dismiss { get; } + + public IObservable Done => Dismiss; + } +} +``` + +## Create a View + +- Create a WPF `UserControl` under `GitHub.VisualStudio\Views\Dialog` +- Add an `ExportViewFor` attribute with the type of the view model interface +- Add a `PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +Continuing the example above: + +```xml + + + +``` + +```csharp +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + [ExportViewFor(typeof(IExampleDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class ExampleDialogView : UserControl + { + public ExampleDialogView() + { + InitializeComponent(); + } + } +} +``` + +## Show the Dialog! + +To show the dialog you will need an instance of the `IShowDialogService` service. Once you have that, simply call the `Show` method with an instance of your view model. + +```csharp +var viewModel = new ExampleDialogViewModel(); +showDialog.Show(viewModel) +``` + +## Optional: Add a method to `DialogService` + +Creating a view model like this may be the right thing to do, but it's not very reusable or testable. If you want your dialog to be easy reusable, add a method to `DialogService`: + +```csharp +public async Task ShowExampleDialog() +{ + var viewModel = factory.CreateViewModel(); + await showDialog.Show(viewModel); +} +``` + +Obviously, add this method to `IDialogService` too. + +Note that these methods are `async` - this means that if you need to do asynchronous initialization of your view model, you can do it here before calling `showDialog`. \ No newline at end of file diff --git a/docs/developer/implementing-github-pane-page.md b/docs/developer/implementing-github-pane-page.md new file mode 100644 index 0000000000..bd17b65af6 --- /dev/null +++ b/docs/developer/implementing-github-pane-page.md @@ -0,0 +1,122 @@ +# Implementing a GitHub Pane Page + +The GitHub pane displays GitHub-specific functionality in a dockable pane. To add a new page to the GitHub pane, you need to do the following: + +## Create a View Model and Interface + +- Create an interface for the view model that implements `IPanePageViewModel` in `GitHub.Exports.Reactive\ViewModels\GitHubPane` +- Create a view model that inherits from `PanePageViewModelBase` and implements the interface in `GitHub.App\ViewModels\GitHubPane` +- Export the view model with the interface as the contract and add a `[PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +A minimal example that just exposes a command that will navigate to the pull request list: + +```csharp +using System; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + public interface IExamplePaneViewModel : IPanePageViewModel + { + ReactiveCommand GoToPullRequests { get; } + } +} +``` + +```csharp +using System; +using System.ComponentModel.Composition; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + [Export(typeof(IExamplePaneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ExamplePaneViewModel : PanePageViewModelBase, IExamplePaneViewModel + { + [ImportingConstructor] + public ExamplePaneViewModel() + { + GoToPullRequests = ReactiveCommand.Create(); + GoToPullRequests.Subscribe(_ => NavigateTo("/pulls")); + } + + public ReactiveCommand GoToPullRequests { get; } + } +} +``` + +## Create a View + +- Create a WPF `UserControl` under `GitHub.VisualStudio\ViewsGitHubPane` +- Add an `ExportViewFor` attribute with the type of the view model interface +- Add a `PartCreationPolicy(CreationPolicy.NonShared)]` attribute + +Continuing the example above: + +```xml + + + + +``` + +```csharp +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + [ExportViewFor(typeof(IExampleDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class ExampleDialogView : UserControl + { + public ExampleDialogView() + { + InitializeComponent(); + } + } +} +``` + +## Add a Route to GitHubPaneViewModel + +Now you need to add a route to the `GitHubPaneViewModel`. To add a route, you must do two things: + +- Add a method to `GitHubPaneViewModel` +- Add a URL handler to `GitHubPaneViewModel.NavigateTo` + +So lets add the `ShowExample` method to `GitHubPaneViewModel`: + +```csharp +public Task ShowExample() +{ + return NavigateTo(x => Task.CompletedTask); +} +``` +Here we call `NavigateTo` with the type of the interface of our view model. We're passing a lambda that simply returns `Task.CompletedTask` as the parameter: usually here you'd call an async initialization method on the view model, but since we don't have one in our simple example we just return a completed task. + +Next we add a URL handler: our URL is going to be `github://pane/example` so we need to add a route that checks that the URL's `AbsolutePath` is `/example` and if so call the method we added above. This code is added to `GitHubPaneViewModel.NavigateTo`: + +```csharp +else if (uri.AbsolutePath == "/example") +{ + await ShowExample(); +} +``` + +For the sake of the example, we're going to show our new page as soon as the GitHub Pane is shown and the user is logged-in with an open repository. To do this, simply change `GitHubPaneViewModel.ShowDefaultPage` to the following: + +```csharp +public Task ShowDefaultPage() => ShowExample(); +``` + +When you run the extension and show the GitHub pane, our new example page should be shown. Clicking on the button in the page will navigate to the pull request list. \ No newline at end of file diff --git a/docs/developer/multi-paged-dialogs.md b/docs/developer/multi-paged-dialogs.md new file mode 100644 index 0000000000..1e014f516c --- /dev/null +++ b/docs/developer/multi-paged-dialogs.md @@ -0,0 +1,77 @@ +# Multi-paged Dialogs + +Some dialogs will be multi-paged - for example the login dialog has a credentials page and a 2Fa page that is shown if two-factor authorization is required. + +## The View Model + +To help implement view models for a multi-page dialog there is a useful base class called `PagedDialogViewModelBase`. The typical way of implementing this is as follows: + +- Define each page of the dialog as you would [implement a single dialog view model](implementing-a-dialog-view.md) +- Implement a "container" view model for the dialog that inherits from `PagedDialogViewModel` +- Import each page into the container view model +- Add logic to switch between pages by setting the `PagedDialogViewModelBase.Content` property +- Add a `Done` observable + +Here's a simple example of a container dialog that has two pages. The pages are switched using `ReactiveCommand`s: + +```csharp +using System; +using System.ComponentModel.Composition; + +namespace GitHub.ViewModels.Dialog +{ + [Export(typeof(IExamplePagedDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ExamplePagedDialogViewModel : PagedDialogViewModelBase, + IExamplePagedDialogViewModel + { + [ImportingConstructor] + public ExamplePagedDialogViewModel( + IPage1ViewModel page1, + IPage2ViewModel page2) + { + Content = page1; + page1.Next.Subscribe(_ => Content = page2); + page2.Previous.Subscribe(_ => Content = page1); + Done = Observable.Merge(page2.Done, page2.Done); + } + + public override IObservable Done { get; } + } +} +``` + +## The View + +The view in this case is very simple: it just needs to display the `Content` property of the container view model: + +```xml + + +``` + +```csharp +using System; +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + [ExportViewFor(typeof(IExamplePagedDialogViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class ExamplePagedDialogView : UserControl + { + public NewLoginView() + { + InitializeComponent(); + } + } +} +``` + +> Note: this is such a common pattern, you don't actually need to define your own view! Simply add the `[ExportViewFor(...)]` attribute to the existing `ContentView` class. \ No newline at end of file diff --git a/docs/developer/readme.md b/docs/developer/readme.md new file mode 100644 index 0000000000..b0c06b2e81 --- /dev/null +++ b/docs/developer/readme.md @@ -0,0 +1,10 @@ +# Developer Documentation + +Documentation for hacking on GitHub for Visual Studio: + +- User Interface + - [How ViewModels are Turned into Views](how-viewmodels-are-turned-into-views.md) + - [Implementing a Dialog View](implementing-a-dialog-view.md) + - [Dialog Views with Connections](dialog-views-with-connections.md) + - [Multi-Paged Dialogs](multi-paged-dialogs.md) + diff --git a/documentation/UIController.md b/documentation/UIController.md deleted file mode 100644 index 60b9400a3f..0000000000 --- a/documentation/UIController.md +++ /dev/null @@ -1,59 +0,0 @@ -The `UIController` class creates views and associated viewmodels for each UI we have, -and controls the UI logic graph. It uses various state machines to define -the UI logic graph. - -**State machines** - -The UI logic graph is controlled by various state machines defined in -`ConfigureLogicStates` and `ConfigureUIHandlingStates` - -**ConfigureLogicStates** - -`ConfigureLogicStates` defines the one state machine per UI group: - - Authentication - - Repository Clone - - Repository Creation - - Repository Publish, - - Pull Requests (List, Detail, Creation) - -All state machines have a common state of `None` (nothing happened yet), -`End` (we're done, cleanup) and `Finish` (final state once cleanup is done), and -UI-specific states that relate to what views the UI needs to show (Login, TwoFactor, -PullRequestList, etc). States are defined in the enum `UIViewType` - -All state machines support a variety of triggers for going from one state to another. -These triggers are defined in the enum `Trigger`. `Cancel` and `Finish` are -supported by all states (any state machine regardless of its current state supports -exiting via `Cancel` (ends with success flag set to false) and `Finish` (ends with success flag set to true). -Since most UI flows we support are linear (login followed by 2fa followed by clone -followed by ending the flow), most states support the `Next` trigger to continue, -and when at the end of a ui flow, `Next` ends it with success. - -The Pull Requests UI flow is non-linear (there are more than one transition from the -list view - it can go to the detail view, or the pr creation view, or other views), -it does not support the `Next` trigger and instead has its own triggers. - -**ConfigureUIHandlingStates** - -`ConfigureUIHandlingStates` defines a state machine `uiStateMachine` connected -to the `transition` observable, and executes whatever logic is needed to load the -requested state, usually by creating the view and viewmodel that the UI state requires. -Whenever a state transition happens in `uiStateMachine`, if the state requires -loading a view, this view is sent back to the rendering code via the `transition` -observable. When the state machine reaches the `End` state, the `completion` observable -is completed with a flag indicating whether the ui flow ended successfully or was -cancelled (via the `Next`/`Finish` triggers or the `Cancel` trigger) . - -The transitions between states are dynamically evaluated at runtime based on the logic -defined in `ConfigureLogicStates`. When `uiStateMachine` receives a trigger request, -it passes that trigger to the state machine corresponding to the ui flow that's currently -running (Authentication, Clone, etc), and that state machine determines which state -to go to next. - -In theory, `uiStateMachine` has no knowledge of the logic graph, and -the only thing it's responsible for is loading UI whenever a state is entered, and cleaning -up objects when things are done - making it essentially a big switch with nice entry and exit -conditions. In practice, we need to configure the valid triggers from one state to the other -just so we can call the corresponding state machine that handles the logic (defined in `ConfigureLogicStates`). -There is a bit of code duplication because of this, and there's some room for improvement -here, but because we have a small number of triggers, it's not a huge deal. \ No newline at end of file diff --git a/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs b/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs index a35fff0849..74214a1741 100644 --- a/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs +++ b/src/GitHub.App/Authentication/TwoFactorChallengeHandler.cs @@ -8,6 +8,7 @@ using GitHub.Api; using GitHub.Helpers; using GitHub.Extensions; +using GitHub.ViewModels.Dialog; namespace GitHub.Authentication { @@ -16,11 +17,11 @@ namespace GitHub.Authentication [PartCreationPolicy(CreationPolicy.Shared)] public class TwoFactorChallengeHandler : ReactiveObject, IDelegatingTwoFactorChallengeHandler { - ITwoFactorDialogViewModel twoFactorDialog; + ILogin2FaViewModel twoFactorDialog; public IViewModel CurrentViewModel { get { return twoFactorDialog; } - private set { this.RaiseAndSetIfChanged(ref twoFactorDialog, (ITwoFactorDialogViewModel)value); } + private set { this.RaiseAndSetIfChanged(ref twoFactorDialog, (ILogin2FaViewModel)value); } } public void SetViewModel(IViewModel vm) @@ -50,7 +51,7 @@ public async Task HandleTwoFactorException(TwoFactorAu public async Task ChallengeFailed(Exception exception) { await ThreadingHelper.SwitchToMainThreadAsync(); - await twoFactorDialog.Cancel.ExecuteAsync(null); + twoFactorDialog.Cancel(); } } } \ No newline at end of file diff --git a/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs b/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs index f1809d3926..726d074600 100644 --- a/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs +++ b/src/GitHub.App/Authentication/TwoFactorRequiredUserError.cs @@ -8,11 +8,18 @@ namespace GitHub.Authentication public class TwoFactorRequiredUserError : UserError { public TwoFactorRequiredUserError(TwoFactorAuthorizationException exception) + : this(exception, exception.TwoFactorType) + { + } + + public TwoFactorRequiredUserError( + TwoFactorAuthorizationException exception, + TwoFactorType twoFactorType) : base(exception.Message, innerException: exception) { Guard.ArgumentNotNull(exception, nameof(exception)); - TwoFactorType = exception.TwoFactorType; + TwoFactorType = twoFactorType; RetryFailed = exception is TwoFactorChallengeFailedException; } diff --git a/src/GitHub.App/Controllers/NavigationController.cs b/src/GitHub.App/Controllers/NavigationController.cs deleted file mode 100644 index 420a2d142f..0000000000 --- a/src/GitHub.App/Controllers/NavigationController.cs +++ /dev/null @@ -1,249 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Disposables; -using GitHub.Models; -using GitHub.Primitives; -using GitHub.Services; -using GitHub.UI; -using GitHub.ViewModels; -using System.Diagnostics; - -namespace GitHub.Controllers -{ - public class NavigationController : NotificationAwareObject, IDisposable, IHasBusy - { - readonly List history = new List(); - readonly Dictionary reusableControllers = new Dictionary(); - readonly IUIProvider uiProvider; - - int current = -1; - - public bool HasBack => current > 0; - public bool HasForward => current < history.Count - 1; - public IUIController Current => current >= 0 ? history[current] : null; - - readonly CompositeDisposable disposablesForCurrentView = new CompositeDisposable(); - - int Pointer - { - get - { - return current; - } - set - { - if (current == value) - return; - - bool raiseBack = false, raiseForward = false; - if ((value == 0 && HasBack) || (value > 0 && !HasBack)) - raiseBack = true; - if ((value == history.Count - 1 && !HasForward) || (value < history.Count - 1 && HasForward)) - raiseForward = true; - current = value; - this.RaisePropertyChanged(nameof(Current)); - if (raiseBack) this.RaisePropertyChanged(nameof(HasBack)); - if (raiseForward) this.RaisePropertyChanged(nameof(HasForward)); - } - } - - bool isBusy; - public bool IsBusy - { - get { return isBusy; } - set { isBusy = value; this.RaisePropertyChanged(); } - } - - public NavigationController(IUIProvider uiProvider) - { - this.uiProvider = uiProvider; - } - - public void LoadView(IConnection connection, ViewWithData data, Action onViewLoad) - { - switch (data.MainFlow) - { - case UIControllerFlow.PullRequestCreation: - if (data.Data == null && Current?.SelectedFlow == UIControllerFlow.PullRequestCreation) - { - Reload(); - } - else - { - CreateView(connection, data, onViewLoad); - } - break; - - case UIControllerFlow.PullRequestDetail: - if (data.Data == null && Current?.SelectedFlow == UIControllerFlow.PullRequestDetail) - { - Reload(); - } - else - { - CreateView(connection, data, onViewLoad); - } - break; - - case UIControllerFlow.PullRequestList: - case UIControllerFlow.Home: - default: - if (data.MainFlow == Current?.SelectedFlow) - { - Reload(); - } - else - { - CreateOrReuseView(connection, data, onViewLoad); - } - break; - } - } - - /// - /// Existing views are not reused - /// - /// - /// - /// - void CreateView(IConnection connection, ViewWithData data, Action onViewLoad) - { - IsBusy = true; - - var controller = CreateController(connection, data, onViewLoad); - Push(controller); - } - - void CreateOrReuseView(IConnection connection, ViewWithData data, Action onViewLoad) - { - IUIController controller; - var exists = reusableControllers.TryGetValue(data.MainFlow, out controller); - - if (!exists) - { - IsBusy = true; - - Action handler = view => - { - disposablesForCurrentView?.Clear(); - - var action = view.ViewModel as ICanNavigate; - if (action != null) - { - disposablesForCurrentView.Add(action?.Navigate.Subscribe(d => - { - LoadView(connection, d, onViewLoad); - })); - } - onViewLoad?.Invoke(view); - }; - - controller = CreateController(connection, data, handler); - reusableControllers.Add(data.MainFlow, controller); - } - - Push(controller); - - if (exists) - { - Reload(); - } - } - - public void Reload() - { - if (IsBusy) - return; - IsBusy = true; - Current?.Reload(); - } - - public void Back() - { - if (!HasBack) - return; - Pointer--; - Reload(); - } - - public void Forward() - { - if (!HasForward) - return; - Pointer++; - Reload(); - } - - IUIController CreateController(IConnection connection, ViewWithData data, Action onViewLoad) - { - var controller = uiProvider.Configure(data.MainFlow, connection, data); - controller.TransitionSignal.Subscribe( - loadData => - { - onViewLoad?.Invoke(loadData.View); - IsBusy = false; - }, - () => { - Pop(controller); - Reload(); - }); - controller.Start(); - return controller; - } - - void Push(IUIController controller) - { - while (history.Count > Pointer + 1) - { - history.RemoveAt(history.Count - 1); - } - - history.Add(controller); - Pointer++; - } - - void Pop(IUIController controller = null) - { - var c = current; - controller = controller ?? history[history.Count - 1]; - var count = history.Count; - for (int i = 0; i < count; i++) - { - if (history[i] == controller) - { - history.RemoveAt(i); - if (i <= c) - c--; - i--; - count--; - } - } - reusableControllers.Remove(controller.SelectedFlow); - controller.Stop(); - Pointer = c; - } - - bool disposed = false; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (!disposed) - { - disposed = true; - disposablesForCurrentView.Dispose(); - reusableControllers.Values.ForEach(c => uiProvider.StopUI(c)); - reusableControllers.Clear(); - history.Clear(); - } - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Controllers/UIController.cs b/src/GitHub.App/Controllers/UIController.cs deleted file mode 100644 index 36d5bbeef3..0000000000 --- a/src/GitHub.App/Controllers/UIController.cs +++ /dev/null @@ -1,713 +0,0 @@ -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using ReactiveUI; -using Stateless; -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Diagnostics; -using System.Linq; -using System.Reactive.Concurrency; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Reactive.Threading.Tasks; -using System.Windows; -using GitHub.Logging; - -namespace GitHub.Controllers -{ - /* - This class creates views and associated viewmodels for each UI we have, - and controls the UI logic graph. It uses various state machines to define - the UI logic graph. - - **State machines** - - The UI logic graph is controlled by various state machines defined in - `ConfigureLogicStates` and `ConfigureUIHandlingStates` - - **ConfigureLogicStates** - - `ConfigureLogicStates` defines the one state machine per UI group: - - Authentication - - Repository Clone - - Repository Creation - - Repository Publish, - - Pull Requests (List, Detail, Creation) - - All state machines have a common state of `None` (nothing happened yet), - `End` (we're done, cleanup) and `Finish` (final state once cleanup is done), and - UI-specific states that relate to what views the UI needs to show (Login, TwoFactor, - PullRequestList, etc). States are defined in the enum `UIViewType` - - All state machines support a variety of triggers for going from one state to another. - These triggers are defined in the enum `Trigger`. `Cancel` and `Finish` are - supported by all states (any state machine regardless of its current state supports - exiting via `Cancel` (ends with success flag set to false) and `Finish` (ends with success flag set to true). - Since most UI flows we support are linear (login followed by 2fa followed by clone - followed by ending the flow), most states support the `Next` trigger to continue, - and when at the end of a ui flow, `Next` ends it with success. - - The Pull Requests UI flow is non-linear (there are more than one transition from the - list view - it can go to the detail view, or the pr creation view, or other views), - it does not support the `Next` trigger and instead has its own triggers. - - **ConfigureUIHandlingStates** - - `ConfigureUIHandlingStates` defines a state machine `uiStateMachine` connected - to the `transition` observable, and executes whatever logic is needed to load the - requested state, usually by creating the view and viewmodel that the UI state requires. - Whenever a state transition happens in `uiStateMachine`, if the state requires - loading a view, this view is sent back to the rendering code via the `transition` - observable. When the state machine reaches the `End` state, the `completion` observable - is completed with a flag indicating whether the ui flow ended successfully or was - cancelled (via the `Next`/`Finish` triggers or the `Cancel` trigger) . - - The transitions between states are dynamically evaluated at runtime based on the logic - defined in `ConfigureLogicStates`. When `uiStateMachine` receives a trigger request, - it passes that trigger to the state machine corresponding to the ui flow that's currently - running (Authentication, Clone, etc), and that state machine determines which state - to go to next. - - In theory, `uiStateMachine` has no knowledge of the logic graph, and - the only thing it's responsible for is loading UI whenever a state is entered, and cleaning - up objects when things are done - making it essentially a big switch with nice entry and exit - conditions. In practice, we need to configure the valid triggers from one state to the other - just so we can call the corresponding state machine that handles the logic (defined in `ConfigureLogicStates`). - There is a bit of code duplication because of this, and there's some room for improvement - here, but because we have a small number of triggers, it's not a huge deal. - */ - - using App.Factories; - using ViewModels; - using StateMachineType = StateMachine; - - public class UIController : IUIController - { - internal enum Trigger - { - None, - Cancel, - Next, - Reload, - Finish - } - - readonly IUIFactory factory; - readonly IGitHubServiceProvider gitHubServiceProvider; - readonly IConnectionManager connectionManager; - - readonly CompositeDisposable disposables = new CompositeDisposable(); - - // holds state machines for each of the individual ui flows - // does not load UI, merely tracks valid transitions - readonly Dictionary machines = - new Dictionary(); - - readonly Dictionary> uiObjects = - new Dictionary>(); - - // loads UI for each state corresponding to a view type - // queries the individual ui flow (stored in machines) for the next state - // for a given transition trigger - readonly StateMachineType uiStateMachine; - - readonly Dictionary> triggers = - new Dictionary>(); - - Subject transition; - public IObservable TransitionSignal => transition; - - // the main ui flow that this ui controller was set up to control - UIControllerFlow selectedFlow; - - // The currently active ui flow. This might be different from the main ui flow - // setup at the start if, for instance, the ui flow is tied - // to a connection that requires being logged in. In this case, when loading - // the ui, the active flow is switched to authentication until it's done, and then - // back to the main ui flow. - UIControllerFlow activeFlow; - NotifyCollectionChangedEventHandler connectionAdded; - - Subject completion; - IConnection connection; - ViewWithData requestedTarget; - bool stopping; - - public UIController(IGitHubServiceProvider serviceProvider) - : this(serviceProvider, - serviceProvider.TryGetService(), - serviceProvider.TryGetService()) - { - Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); - } - - public UIController(IGitHubServiceProvider gitHubServiceProvider, - IUIFactory factory, - IConnectionManager connectionManager) - { - Guard.ArgumentNotNull(gitHubServiceProvider, nameof(gitHubServiceProvider)); - Guard.ArgumentNotNull(factory, nameof(factory)); - Guard.ArgumentNotNull(connectionManager, nameof(connectionManager)); - - this.factory = factory; - this.gitHubServiceProvider = gitHubServiceProvider; - this.connectionManager = connectionManager; - -#if DEBUG - if (Application.Current != null && !Splat.ModeDetector.InUnitTestRunner()) - { - var waitDispatcher = RxApp.MainThreadScheduler as WaitForDispatcherScheduler; - if (waitDispatcher != null) - { - Log.Assert(DispatcherScheduler.Current.Dispatcher == Application.Current.Dispatcher, - "DispatcherScheduler is set correctly"); - } - else - { - Log.Assert(((DispatcherScheduler)RxApp.MainThreadScheduler).Dispatcher == Application.Current.Dispatcher, - "The MainThreadScheduler is using the wrong dispatcher"); - } - } -#endif - ConfigureLogicStates(); - - uiStateMachine = new StateMachineType(UIViewType.None); - - ConfigureUIHandlingStates(); - - } - - public IObservable Configure(UIControllerFlow choice, - IConnection conn = null, - ViewWithData parameters = null) - { - connection = conn; - selectedFlow = choice; - requestedTarget = parameters; - - transition = new Subject(); - transition.Subscribe(_ => {}, _ => Fire(Trigger.Next)); - - return transition; - } - - /// - /// Allows listening to the completion state of the ui flow - whether - /// it was completed because it was cancelled or whether it succeeded. - /// - /// true for success, false for cancel - public IObservable ListenToCompletionState() - { - if (completion == null) - completion = new Subject(); - return completion; - } - - public void Start() - { - if (connection != null) - { - if (selectedFlow != UIControllerFlow.Authentication) - gitHubServiceProvider.AddService(this, connection); - else // sanity check: it makes zero sense to pass a connection in when calling the auth flow - Log.Assert(false, "Calling the auth flow with a connection makes no sense!"); - - connectionManager.GetLoadedConnections().ToObservable() - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => { }, () => - { - Debug.WriteLine("Start ({0})", GetHashCode()); - Fire(Trigger.Next); - }); - } - else - { - connectionManager - .GetLoggedInConnections() - .FirstOrDefaultAsync() - .Select(c => - { - bool loggedin = c != null; - if (selectedFlow != UIControllerFlow.Authentication) - { - if (loggedin) // register the first available connection so the viewmodel can use it - { - connection = c; - gitHubServiceProvider.AddService(this, c); - } - else - { - // a connection will be added to the list when auth is done, register it so the next - // viewmodel can use it - connectionAdded = (s, e) => - { - if (e.Action == NotifyCollectionChangedAction.Add) - { - connection = e.NewItems[0] as IConnection; - if (connection != null) - gitHubServiceProvider.AddService(typeof(IConnection), this, connection); - } - }; - connectionManager.Connections.CollectionChanged += connectionAdded; - } - } - return loggedin; - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => { }, () => - { - Debug.WriteLine("Start ({0})", GetHashCode()); - Fire(Trigger.Next); - }); - } - } - - public void Stop() - { - if (stopping || transition == null) - return; - - Debug.WriteLine("Stopping {0} ({1})", activeFlow + (activeFlow != selectedFlow ? " and " + selectedFlow : ""), GetHashCode()); - stopping = true; - Fire(Trigger.Finish); - } - - public void Reload() - { - Fire(Trigger.Reload); - } - - /// - /// Configures the UI that gets loaded when entering a certain state and which state - /// to go to for each trigger. Which state to go to depends on which ui flow state machine - /// is currently active - when a trigger happens, the PermitDynamic conditions will - /// lookup the currently active state machine from the list of available ones in `machines`, - /// fire the requested trigger on it, and return the state that it went to, which causes - /// `uiStateMachine` to load a new UI (or exit) - /// There is a bit of redundant information regarding valid state transitions between this - /// state machine and the individual state machines for each ui flow. This is unavoidable - /// because permited transition triggers have to be explicit, so care should be taken to - /// make sure permitted triggers per view here match the permitted triggers in the individual - /// state machines. - /// - void ConfigureUIHandlingStates() - { - triggers.Add(Trigger.Next, uiStateMachine.SetTriggerParameters(Trigger.Next)); - - uiStateMachine.Configure(UIViewType.None) - .OnEntry(tr => stopping = false) - .PermitDynamic(Trigger.Next, () => - { - activeFlow = SelectActiveFlow(); - return Go(Trigger.Next); - }) - .PermitDynamic(Trigger.Finish, () => Go(Trigger.Finish)); - - ConfigureEntriesExitsForView(UIViewType.Clone); - ConfigureEntriesExitsForView(UIViewType.Create); - ConfigureEntriesExitsForView(UIViewType.Publish); - ConfigureEntriesExitsForView(UIViewType.PRList); - ConfigureEntriesExitsForView(UIViewType.PRDetail); - ConfigureEntriesExitsForView(UIViewType.PRCreation); - ConfigureEntriesExitsForView(UIViewType.Login); - ConfigureEntriesExitsForView(UIViewType.TwoFactor); - ConfigureEntriesExitsForView(UIViewType.Gist); - ConfigureEntriesExitsForView(UIViewType.StartPageClone); - - uiStateMachine.Configure(UIViewType.End) - .OnEntryFrom(Trigger.Cancel, () => End(false)) - .OnEntryFrom(Trigger.Next, () => End(true)) - .OnEntryFrom(Trigger.Finish, () => End(true)) - // clear all the views and viewmodels created by a subflow - .OnExit(() => - { - // it's important to have the stopping flag set before we do this - if (activeFlow == selectedFlow) - { - completion?.OnNext(Success.Value); - completion?.OnCompleted(); - } - - DisposeFlow(activeFlow); - - if (activeFlow == selectedFlow) - { - gitHubServiceProvider.RemoveService(typeof(IConnection), this); - transition.OnCompleted(); - Reset(); - } - else - activeFlow = stopping || LoggedIn ? selectedFlow : UIControllerFlow.Authentication; - }) - .PermitDynamic(Trigger.Next, () => - { - // sets the state to None for the current flow - var state = Go(Trigger.Next); - - if (activeFlow != selectedFlow) - { - if (stopping) - { - // triggering the End state again so that it can clear up the main flow and really end - state = Go(Trigger.Finish, selectedFlow); - } - else - { - // sets the state to the first UI of the main flow or the auth flow, depending on whether you're logged in - state = Go(Trigger.Next, LoggedIn ? selectedFlow : UIControllerFlow.Authentication); - } - } - return state; - }) - .Permit(Trigger.Finish, UIViewType.None); - } - - StateMachineType.StateConfiguration ConfigureEntriesExitsForView(UIViewType viewType) - { - return uiStateMachine.Configure(viewType) - .OnEntryFrom(triggers[Trigger.Next], (arg, tr) => RunView(tr.Destination, arg)) - .OnEntry(tr => { - // Trigger.Next is always called in OnEntryFrom, don't want to run it twice - if (tr.Trigger != Trigger.Next) RunView(tr.Destination); - }) - .PermitReentry(Trigger.Reload) - .PermitDynamic(Trigger.Next, () => Go(Trigger.Next)) - .PermitDynamic(Trigger.Cancel, () => Go(Trigger.Cancel)) - .PermitDynamic(Trigger.Finish, () => Go(Trigger.Finish)); - } - - /// - /// Configure all the logical state transitions for each of the - /// ui flows we support that have more than one view - /// - void ConfigureLogicStates() - { - StateMachineType logic; - - // no selected flow - logic = new StateMachine(UIViewType.None); - logic.Configure(UIViewType.None) - .Ignore(Trigger.Next) - .Ignore(Trigger.Finish); - machines.Add(UIControllerFlow.None, logic); - - // authentication flow - logic = new StateMachine(UIViewType.None); - logic.Configure(UIViewType.None) - .Permit(Trigger.Next, UIViewType.Login) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.Login) - .Permit(Trigger.Next, UIViewType.TwoFactor) - .Permit(Trigger.Cancel, UIViewType.End) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.TwoFactor) - .Permit(Trigger.Next, UIViewType.End) - .Permit(Trigger.Cancel, UIViewType.Login) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.End) - .Permit(Trigger.Next, UIViewType.None); - - machines.Add(UIControllerFlow.Authentication, logic); - - ConfigureSingleViewLogic(UIControllerFlow.Clone, UIViewType.Clone); - ConfigureSingleViewLogic(UIControllerFlow.Create, UIViewType.Create); - ConfigureSingleViewLogic(UIControllerFlow.Gist, UIViewType.Gist); - ConfigureSingleViewLogic(UIControllerFlow.Home, UIViewType.PRList); - ConfigureSingleViewLogic(UIControllerFlow.Publish, UIViewType.Publish); - ConfigureSingleViewLogic(UIControllerFlow.PullRequestList, UIViewType.PRList); - ConfigureSingleViewLogic(UIControllerFlow.PullRequestDetail, UIViewType.PRDetail); - ConfigureSingleViewLogic(UIControllerFlow.PullRequestCreation, UIViewType.PRCreation); - ConfigureSingleViewLogic(UIControllerFlow.ReClone, UIViewType.StartPageClone); - } - - void ConfigureSingleViewLogic(UIControllerFlow flow, UIViewType type) - { - var logic = new StateMachineType(UIViewType.None); - logic.Configure(UIViewType.None) - .Permit(Trigger.Next, type) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(type) - .Permit(Trigger.Next, UIViewType.End) - .Permit(Trigger.Cancel, UIViewType.End) - .Permit(Trigger.Finish, UIViewType.End); - logic.Configure(UIViewType.End) - .Permit(Trigger.Next, UIViewType.None); - machines.Add(flow, logic); - } - - UIControllerFlow SelectActiveFlow() - { - var loggedIn = connection?.IsLoggedIn ?? false; - return loggedIn ? selectedFlow : UIControllerFlow.Authentication; - } - - /// - /// End state for a flow has been called. Clear handlers related to that flow - /// and call completion handlers if this means the controller is being stopped. - /// Handlers registered via `ListenToCompletionState` might need to access the - /// view/viewmodel objects, so those can only be disposed when we're leaving - /// the End state, not here. - /// - /// - void End(bool success) - { - if (!Success.HasValue) - Success = success; - - ShutdownFlow(activeFlow); - - // if the auth was cancelled, we need to stop everything, otherwise we'll go into a loop - if (activeFlow == selectedFlow || !Success.Value) - stopping = true; - - Fire(Trigger.Next); - } - - void ShutdownFlow(UIControllerFlow flow) - { - var list = GetObjectsForFlow(flow); - foreach (var i in list.Values) - i.ClearHandlers(); - } - - void DisposeFlow(UIControllerFlow flow) - { - var list = GetObjectsForFlow(flow); - foreach (var i in list.Values) - i.Dispose(); - list.Clear(); - } - - void DisposeView(UIControllerFlow flow, UIViewType type) - { - var list = GetObjectsForFlow(flow); - IUIPair uipair = null; - if (list.TryGetValue(type, out uipair)) - { - list.Remove(type); - uipair.Dispose(); - } - } - - void RunView(UIViewType viewType, ViewWithData arg = null) - { - if (requestedTarget?.ViewType == viewType || (requestedTarget?.ViewType == UIViewType.None && requestedTarget?.MainFlow == CurrentFlow)) - { - arg = requestedTarget; - } - - if (arg == null) - arg = new ViewWithData { ActiveFlow = activeFlow, MainFlow = selectedFlow, ViewType = viewType }; - bool firstTime = CreateViewAndViewModel(viewType, arg); - var view = GetObjectsForFlow(activeFlow)[viewType].View; - transition.OnNext(new LoadData - { - Flow = activeFlow, - View = view, - Data = arg - }); - - // controller might have been stopped in the OnNext above - if (IsStopped) - return; - - // if it's not the first time we've shown this view, no need - // to set it up - if (!firstTime) - return; - - SetupView(viewType, view.ViewModel); - } - - void SetupView(UIViewType viewType, IViewModel viewModel) - { - var list = GetObjectsForFlow(activeFlow); - var pair = list[viewType]; - var hasDone = viewModel as IHasDone; - var hasCancel = viewModel as IHasCancel; - - // 2FA is set up when login is set up, so nothing to do - if (viewType == UIViewType.TwoFactor) - return; - - // we're setting up the login dialog, we need to setup the 2fa as - // well to continue the flow if it's needed, since the - // authenticationresult callback won't happen until - // everything is done - if (viewType == UIViewType.Login) - { - var pair2fa = list[UIViewType.TwoFactor]; - pair2fa.AddHandler(((IDialogViewModel)pair2fa.ViewModel).WhenAny(x => x.IsShowing, x => x.Value) - .Where(x => x) - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(Trigger.Next))); - - pair2fa.AddHandler(((IHasCancel)pair2fa.ViewModel).Cancel - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(uiStateMachine.CanFire(Trigger.Cancel) ? Trigger.Cancel : Trigger.Finish))); - - if (hasDone != null) - { - pair.AddHandler(hasDone.Done - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(Trigger.Finish))); - } - } - else if (hasDone != null) - { - pair.AddHandler(hasDone.Done - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(uiStateMachine.CanFire(Trigger.Next) ? Trigger.Next : Trigger.Finish))); - } - - if (hasCancel != null) - { - pair.AddHandler(hasCancel.Cancel - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => Fire(uiStateMachine.CanFire(Trigger.Cancel) ? Trigger.Cancel : Trigger.Finish))); - } - } - - /// - /// Creates View/ViewModel instances for the specified if they - /// haven't been created yet in the current flow - /// - /// - /// true if the View/ViewModel didn't exist and had to be created - bool CreateViewAndViewModel(UIViewType viewType, ViewWithData data = null) - { - var list = GetObjectsForFlow(activeFlow); - if (viewType == UIViewType.Login) - { - if (!list.ContainsKey(viewType)) - { - var d = factory.CreateViewAndViewModel(UIViewType.TwoFactor); - list.Add(UIViewType.TwoFactor, d); - } - } - - // 2fa view/viewmodel is created when login is created 'cause login needs the 2fa viewmodel - // so the only thing we want to do is connect the viewmodel to the view when it's showing - else if (viewType == UIViewType.TwoFactor) - { - var d = list[viewType]; - if (d.View.ViewModel == null) - { - d.ViewModel.Initialize(data); - d.View.DataContext = d.ViewModel; - } - } - - IUIPair pair = null; - var firstTime = !list.TryGetValue(viewType, out pair); - - if (firstTime) - pair = factory.CreateViewAndViewModel(viewType); - - pair.ViewModel.Initialize(data); - - if (firstTime) - { - pair.View.DataContext = pair.ViewModel; - list.Add(viewType, pair); - } - - return firstTime; - } - - - /// - /// Returns the view/viewmodel pair for a given flow - /// - Dictionary GetObjectsForFlow(UIControllerFlow flow) - { - Dictionary list; - if (!uiObjects.TryGetValue(flow, out list)) - { - list = new Dictionary(); - uiObjects.Add(flow, list); - } - return list; - } - - void Fire(Trigger next, ViewWithData arg = null) - { - Debug.WriteLine("Firing {0} from {1} ({2})", next, uiStateMachine.State, GetHashCode()); - if (triggers.ContainsKey(next)) - uiStateMachine.Fire(triggers[next], arg); - else - uiStateMachine.Fire(next); - } - - UIViewType Go(Trigger trigger) - { - return Go(trigger, activeFlow); - } - - UIViewType Go(Trigger trigger, UIControllerFlow flow) - { - var m = machines[flow]; - Debug.WriteLine("Firing {0} from {1} for flow {2} ({3})", trigger, m.State, flow, GetHashCode()); - m.Fire(trigger); - return m.State; - } - - void Reset() - { - if (connectionAdded != null) - connectionManager.Connections.CollectionChanged -= connectionAdded; - connectionAdded = null; - - var tr = transition; - var cmp = completion; - transition = null; - completion = null; - disposables.Clear(); - tr?.Dispose(); - cmp?.Dispose(); - stopping = false; - connection = null; - } - - bool disposed; // To detect redundant calls - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - disposed = true; - - Debug.WriteLine("Disposing ({0})", GetHashCode()); - - if (connectionAdded != null) - connectionManager.Connections.CollectionChanged -= connectionAdded; - connectionAdded = null; - - var tr = transition; - var cmp = completion; - transition = null; - completion = null; - disposables.Dispose(); - tr?.Dispose(); - cmp?.Dispose(); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public bool IsStopped => uiStateMachine.IsInState(UIViewType.None) || stopping; - public UIControllerFlow CurrentFlow => activeFlow; - public UIControllerFlow SelectedFlow => selectedFlow; - bool LoggedIn => connection?.IsLoggedIn ?? false; - bool? Success { get; set; } - } -} diff --git a/src/GitHub.App/Factories/UIFactory.cs b/src/GitHub.App/Factories/UIFactory.cs deleted file mode 100644 index 5d98499ab9..0000000000 --- a/src/GitHub.App/Factories/UIFactory.cs +++ /dev/null @@ -1,113 +0,0 @@ -using GitHub.Exports; -using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Disposables; -using System.Text; -using System.Threading.Tasks; -using GitHub.Extensions; - -namespace GitHub.App.Factories -{ - [Export(typeof(IUIFactory))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class UIFactory : IUIFactory - { - readonly IExportFactoryProvider factory; - - [ImportingConstructor] - public UIFactory(IExportFactoryProvider factory) - { - this.factory = factory; - } - - /// - /// Creates View/ViewModel instances for the specified if they - /// haven't been created yet in the current flow - /// - /// - /// true if the View/ViewModel didn't exist and had to be created - public IUIPair CreateViewAndViewModel(UIViewType viewType) - { - return new UIPair(viewType, factory.GetView(viewType), factory.GetViewModel(viewType)); - } - - protected virtual void Dispose(bool disposing) - {} - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - } - - /// - /// This class holds ExportLifetimeContexts (i.e., Lazy Disposable containers) for IView and IViewModel objects - /// A view type (login, clone, etc) is composed of a pair of view and viewmodel, which this class represents. - /// - public class UIPair : IUIPair - { - readonly ExportLifetimeContext view; - readonly ExportLifetimeContext viewModel; - readonly CompositeDisposable handlers = new CompositeDisposable(); - readonly UIViewType viewType; - - public UIViewType ViewType => viewType; - public IView View => view.Value; - public IViewModel ViewModel => viewModel?.Value; - - /// The UIViewType - /// The IView - /// The IViewModel. Might be null because the 2fa view shares the same viewmodel as the login dialog, so it's - /// set manually in the view outside of this - public UIPair(UIViewType type, ExportLifetimeContext v, ExportLifetimeContext vm) - { - Guard.ArgumentNotNull(v, nameof(v)); - - viewType = type; - view = v; - viewModel = vm; - handlers = new CompositeDisposable(); - } - - /// - /// Register disposable event handlers or observable subscriptions so they get cleared - /// when the View/Viewmodel get disposed/destroyed - /// - /// - public void AddHandler(IDisposable disposable) - { - handlers.Add(disposable); - } - - public void ClearHandlers() - { - handlers.Dispose(); - } - - bool disposed = false; - void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - if (!handlers.IsDisposed) - handlers.Dispose(); - view?.Dispose(); - viewModel?.Dispose(); - disposed = true; - } - } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.App/Factories/ViewViewModelFactory.cs b/src/GitHub.App/Factories/ViewViewModelFactory.cs new file mode 100644 index 0000000000..73bb6ebc0d --- /dev/null +++ b/src/GitHub.App/Factories/ViewViewModelFactory.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Windows; +using GitHub.Exports; +using GitHub.Services; +using GitHub.ViewModels; + +namespace GitHub.Factories +{ + /// + /// Factory for creating views and view models. + /// + [Export(typeof(IViewViewModelFactory))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class ViewViewModelFactory : IViewViewModelFactory + { + readonly IGitHubServiceProvider serviceProvider; + + [ImportingConstructor] + public ViewViewModelFactory( + IGitHubServiceProvider serviceProvider, + ICompositionService cc) + { + this.serviceProvider = serviceProvider; + cc.SatisfyImportsOnce(this); + } + + [ImportMany(AllowRecomposition = true)] + IEnumerable> Views { get; set; } + + /// + public TViewModel CreateViewModel() where TViewModel : IViewModel + { + return serviceProvider.ExportProvider.GetExport().Value; + } + + /// + public FrameworkElement CreateView() where TViewModel : IViewModel + { + return CreateView(typeof(TViewModel)); + } + + /// + public FrameworkElement CreateView(Type viewModel) + { + var f = Views.FirstOrDefault(x => x.Metadata.ViewModelType.Contains(viewModel)); + return f?.CreateExport().Value; + } + } +} diff --git a/src/GitHub.App/GitHub.App.csproj b/src/GitHub.App/GitHub.App.csproj index cba64faecd..f726e09103 100644 --- a/src/GitHub.App/GitHub.App.csproj +++ b/src/GitHub.App/GitHub.App.csproj @@ -122,6 +122,7 @@ ..\..\packages\Rx-XAML.2.2.5-custom\lib\net45\System.Reactive.Windows.Threading.dll True + @@ -131,19 +132,40 @@ + - + + + + + + + + + + + + + + + + + + + + + + + - - @@ -156,10 +178,9 @@ - - + @@ -191,42 +212,20 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/GitHub.App/SampleData/LoggedOutViewModelDesigner.cs b/src/GitHub.App/SampleData/LoggedOutViewModelDesigner.cs deleted file mode 100644 index afa30b6ca1..0000000000 --- a/src/GitHub.App/SampleData/LoggedOutViewModelDesigner.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Reactive; -using System.Reactive.Linq; -using GitHub.ViewModels; - -namespace GitHub.SampleData -{ - [ExcludeFromCodeCoverage] - public class LoggedOutViewModelDesigner : PanePageViewModelBase, IViewModel - { - public LoggedOutViewModelDesigner() - { - } - } -} \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs index 45e42b9fa2..a4d13c3add 100644 --- a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs @@ -1,16 +1,16 @@ -using System.Diagnostics.CodeAnalysis; -using GitHub.ViewModels; +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; using GitHub.Models; using GitHub.Validation; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; -using System; -using System.Reactive; namespace GitHub.SampleData { [ExcludeFromCodeCoverage] - public class PullRequestCreationViewModelDesigner : DialogViewModelBase, IPullRequestCreationViewModel + public class PullRequestCreationViewModelDesigner : PanePageViewModelBase, IPullRequestCreationViewModel { public PullRequestCreationViewModelDesigner() { @@ -41,6 +41,7 @@ public PullRequestCreationViewModelDesigner() public List Users { get; set; } public IReactiveCommand CreatePullRequest { get; } + public IReactiveCommand Cancel { get; } public string PRTitle { get; set; } @@ -48,6 +49,6 @@ public PullRequestCreationViewModelDesigner() public ReactivePropertyValidator BranchValidator { get; } - public override IObservable Done { get; } + public Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs index c94c5f02c8..3014a14a8e 100644 --- a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs @@ -7,6 +7,7 @@ using GitHub.Models; using GitHub.Services; using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; namespace GitHub.SampleData @@ -76,11 +77,10 @@ public PullRequestDetailViewModelDesigner() public IPullRequestSession Session { get; } public ILocalRepositoryModel LocalRepository { get; } public string RemoteRepositoryOwner { get; } + public int Number { get; set; } public string SourceBranchDisplayName { get; set; } public string TargetBranchDisplayName { get; set; } public int CommentCount { get; set; } - public bool IsLoading { get; } - public bool IsBusy { get; } public bool IsCheckedOut { get; } public bool IsFromFork { get; } public string Body { get; } @@ -89,6 +89,7 @@ public PullRequestDetailViewModelDesigner() public IPullRequestUpdateState UpdateState { get; set; } public string OperationError { get; set; } public string ErrorMessage { get; set; } + public Uri WebUrl { get; set; } public ReactiveCommand Checkout { get; } public ReactiveCommand Pull { get; } @@ -99,6 +100,8 @@ public PullRequestDetailViewModelDesigner() public ReactiveCommand OpenFileInWorkingDirectory { get; } public ReactiveCommand ViewFile { get; } + public Task InitializeAsync(ILocalRepositoryModel localRepository, IConnection connection, string owner, string repo, int number) => Task.CompletedTask; + public Task ExtractFile(IPullRequestFileNode file, bool head) { return null; diff --git a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs index afaeed1399..7c71afa358 100644 --- a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs @@ -1,15 +1,14 @@ using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Reactive.Linq; -using System.Windows.Input; +using System.Threading.Tasks; using GitHub.Collections; using GitHub.Models; -using GitHub.ViewModels; -using System.Collections.Generic; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; -using System.Collections.ObjectModel; -using System.Linq; -using GitHub.UI; namespace GitHub.SampleData { @@ -75,11 +74,12 @@ public PullRequestListViewModelDesigner() public ObservableCollection Assignees { get; set; } public IAccount SelectedAssignee { get; set; } - public IObservable Navigate { get; } - public bool IsBusy { get; } + public Uri WebUrl { get; set; } public ReactiveCommand OpenPullRequest { get; } public ReactiveCommand CreatePullRequest { get; } public ReactiveCommand OpenPullRequestOnGitHub { get; } + + public Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) => Task.CompletedTask; } } \ No newline at end of file diff --git a/src/GitHub.App/SampleData/StartPageCloneViewModelDesigner.cs b/src/GitHub.App/SampleData/RepositoryRecloneViewModelDesigner.cs similarity index 59% rename from src/GitHub.App/SampleData/StartPageCloneViewModelDesigner.cs rename to src/GitHub.App/SampleData/RepositoryRecloneViewModelDesigner.cs index d4367e4d4d..2cc1b9eee8 100644 --- a/src/GitHub.App/SampleData/StartPageCloneViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/RepositoryRecloneViewModelDesigner.cs @@ -1,20 +1,24 @@ using System; -using System.Reactive; +using System.Threading.Tasks; using System.Windows.Input; using GitHub.Models; using GitHub.Validation; using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; using ReactiveUI; namespace GitHub.SampleData { - public class StartPageCloneViewModelDesigner : DialogViewModelBase, IBaseCloneViewModel + public class RepositoryRecloneViewModelDesigner : ViewModelBase, IRepositoryRecloneViewModel { + public string Title { get; set; } public string BaseRepositoryPath { get; set; } public ReactivePropertyValidator BaseRepositoryPathValidator { get; } public ICommand BrowseForDirectory { get; } public IReactiveCommand CloneCommand { get; } public IRepositoryModel SelectedRepository { get; set; } - public override IObservable Done { get; } + public IObservable Done { get; } + + public Task InitializeAsync(IConnection connection) => Task.CompletedTask; } } diff --git a/src/GitHub.App/SampleData/SampleViewModels.cs b/src/GitHub.App/SampleData/SampleViewModels.cs index afd25ca6ef..4c76d6d614 100644 --- a/src/GitHub.App/SampleData/SampleViewModels.cs +++ b/src/GitHub.App/SampleData/SampleViewModels.cs @@ -1,29 +1,26 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Reactive; +using System.Threading.Tasks; using System.Windows.Input; -using GitHub.Api; -using GitHub.Authentication; using GitHub.Extensions; using GitHub.Models; using GitHub.Primitives; -using GitHub.Services; using GitHub.UI; using GitHub.Validation; using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using GitHub.ViewModels.TeamExplorer; +using GitHub.VisualStudio.TeamExplorer.Connect; using GitHub.VisualStudio.TeamExplorer.Home; using ReactiveUI; -using GitHub.VisualStudio.TeamExplorer.Connect; -using System.Collections.ObjectModel; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; namespace GitHub.SampleData { [ExcludeFromCodeCoverage] - public class RepositoryCreationViewModelDesigner : DialogViewModelBase, IRepositoryCreationViewModel + public class RepositoryCreationViewModelDesigner : ViewModelBase, IRepositoryCreationViewModel { public RepositoryCreationViewModelDesigner() { @@ -55,7 +52,7 @@ public RepositoryCreationViewModelDesigner() SelectedLicense = Licenses[0]; } - public new string Title { get { return "Create a GitHub Repository"; } } // TODO: this needs to be contextual + public string Title { get { return "Create a GitHub Repository"; } } // TODO: this needs to be contextual public IReadOnlyList Accounts { @@ -187,7 +184,9 @@ public LicenseItem SelectedLicense set; } - public override IObservable Done { get; } + public IObservable Done { get; } + + public Task InitializeAsync(IConnection connection) => Task.CompletedTask; } [ExcludeFromCodeCoverage] @@ -216,6 +215,8 @@ public RepositoryPublishViewModelDesigner() SelectedConnection = Connections[0]; } + public bool IsBusy { get; set; } + public bool IsHostComboBoxVisible { get @@ -253,7 +254,7 @@ public static IRemoteRepositoryModel Create(string name = null, string owner = n } } - public class RepositoryCloneViewModelDesigner : DialogViewModelBase, IRepositoryCloneViewModel + public class RepositoryCloneViewModelDesigner : ViewModelBase, IRepositoryCloneViewModel { public RepositoryCloneViewModelDesigner() { @@ -300,7 +301,7 @@ public bool FilterTextIsEnabled public string FilterText { get; set; } - public new string Title { get { return "Clone a GitHub Repository"; } } + public string Title { get { return "Clone a GitHub Repository"; } } public IReactiveCommand> LoadRepositoriesCommand { @@ -343,7 +344,9 @@ public ReactivePropertyValidator BaseRepositoryPathValidator private set; } - public override IObservable Done { get; } + public IObservable Done { get; } + + public Task InitializeAsync(IConnection connection) => Task.CompletedTask; } public class GitHubHomeSectionDesigner : IGitHubHomeSection diff --git a/src/GitHub.App/Services/DialogService.cs b/src/GitHub.App/Services/DialogService.cs index e989a97935..e22fe0cc84 100644 --- a/src/GitHub.App/Services/DialogService.cs +++ b/src/GitHub.App/Services/DialogService.cs @@ -2,9 +2,9 @@ using System.ComponentModel.Composition; using System.Threading.Tasks; using GitHub.Extensions; +using GitHub.Factories; using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; namespace GitHub.Services { @@ -12,65 +12,64 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.NonShared)] public class DialogService : IDialogService { - readonly IUIProvider uiProvider; + readonly IViewViewModelFactory factory; + readonly IShowDialogService showDialog; [ImportingConstructor] - public DialogService(IUIProvider uiProvider) + public DialogService( + IViewViewModelFactory factory, + IShowDialogService showDialog) { - Guard.ArgumentNotNull(uiProvider, nameof(uiProvider)); + Guard.ArgumentNotNull(factory, nameof(factory)); + Guard.ArgumentNotNull(showDialog, nameof(showDialog)); - this.uiProvider = uiProvider; + this.factory = factory; + this.showDialog = showDialog; } - public Task ShowCloneDialog(IConnection connection) + public async Task ShowCloneDialog(IConnection connection) { - var controller = uiProvider.Configure(UIControllerFlow.Clone, connection); - var basePath = default(string); - var repository = default(IRepositoryModel); + var viewModel = factory.CreateViewModel(); - controller.TransitionSignal.Subscribe(x => + if (connection != null) { - var vm = x.View.ViewModel as IBaseCloneViewModel; - - vm?.Done.Subscribe(_ => - { - basePath = vm?.BaseRepositoryPath; - repository = vm?.SelectedRepository; - }); - }); - - uiProvider.RunInDialog(controller); - - var result = repository != null && basePath != null ? - new CloneDialogResult(basePath, repository) : null; - return Task.FromResult(result); + await viewModel.InitializeAsync(connection); + return (CloneDialogResult)await showDialog.Show(viewModel); + } + else + { + return (CloneDialogResult)await showDialog.ShowWithFirstConnection(viewModel); + } } - public Task ShowReCloneDialog(IRepositoryModel repository) + public async Task ShowReCloneDialog(IRepositoryModel repository) { Guard.ArgumentNotNull(repository, nameof(repository)); - var controller = uiProvider.Configure(UIControllerFlow.ReClone); - var basePath = default(string); - - controller.TransitionSignal.Subscribe(x => - { - var vm = x.View.ViewModel as IBaseCloneViewModel; + var viewModel = factory.CreateViewModel(); + viewModel.SelectedRepository = repository; + return (string)await showDialog.ShowWithFirstConnection(viewModel); + } - if (vm != null) - { - vm.SelectedRepository = repository; - } + public async Task ShowCreateGist() + { + var viewModel = factory.CreateViewModel(); + await showDialog.ShowWithFirstConnection(viewModel); + } - vm.Done.Subscribe(_ => - { - basePath = vm?.BaseRepositoryPath; - }); - }); + public async Task ShowCreateRepositoryDialog(IConnection connection) + { + Guard.ArgumentNotNull(connection, nameof(connection)); - uiProvider.RunInDialog(controller); + var viewModel = factory.CreateViewModel(); + await viewModel.InitializeAsync(connection); + await showDialog.Show(viewModel); + } - return Task.FromResult(basePath); + public async Task ShowLoginDialog() + { + var viewModel = factory.CreateViewModel(); + return (IConnection)await showDialog.Show(viewModel); } } } diff --git a/src/GitHub.App/ViewModels/GistCreationViewModel.cs b/src/GitHub.App/ViewModels/Dialog/GistCreationViewModel.cs similarity index 73% rename from src/GitHub.App/ViewModels/GistCreationViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/GistCreationViewModel.cs index 3445ccb2c5..a32531dc35 100644 --- a/src/GitHub.App/ViewModels/GistCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/GistCreationViewModel.cs @@ -1,13 +1,11 @@ using System; using System.ComponentModel.Composition; using System.Linq; -using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; using GitHub.Api; using GitHub.App; -using GitHub.Exports; using GitHub.Extensions; -using GitHub.Extensions.Reactive; using GitHub.Factories; using GitHub.Logging; using GitHub.Models; @@ -17,59 +15,51 @@ using Serilog; using IConnection = GitHub.Models.IConnection; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - [ExportViewModel(ViewType=UIViewType.Gist)] + [Export(typeof(IGistCreationViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class GistCreationViewModel : DialogViewModelBase, IGistCreationViewModel + public class GistCreationViewModel : ViewModelBase, IGistCreationViewModel { static readonly ILogger log = LogManager.ForContext(); - readonly IApiClient apiClient; - readonly ObservableAsPropertyHelper account; + readonly IModelServiceFactory modelServiceFactory; readonly IGistPublishService gistPublishService; readonly INotificationService notificationService; readonly IUsageTracker usageTracker; + IApiClient apiClient; + ObservableAsPropertyHelper account; [ImportingConstructor] - GistCreationViewModel( - IGlobalConnection connection, - IModelServiceFactory modelServiceFactory, - ISelectedTextProvider selectedTextProvider, - IGistPublishService gistPublishService, - INotificationService notificationService, - IUsageTracker usageTracker) - : this(connection.Get(), modelServiceFactory, selectedTextProvider, gistPublishService, usageTracker) - { - Guard.ArgumentNotNull(connection, nameof(connection)); - Guard.ArgumentNotNull(selectedTextProvider, nameof(selectedTextProvider)); - Guard.ArgumentNotNull(gistPublishService, nameof(gistPublishService)); - Guard.ArgumentNotNull(notificationService, nameof(notificationService)); - Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); - - this.notificationService = notificationService; - } - public GistCreationViewModel( - IConnection connection, IModelServiceFactory modelServiceFactory, ISelectedTextProvider selectedTextProvider, IGistPublishService gistPublishService, + INotificationService notificationService, IUsageTracker usageTracker) { - Guard.ArgumentNotNull(connection, nameof(connection)); Guard.ArgumentNotNull(selectedTextProvider, nameof(selectedTextProvider)); Guard.ArgumentNotNull(gistPublishService, nameof(gistPublishService)); Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); - Title = Resources.CreateGistTitle; + this.modelServiceFactory = modelServiceFactory; this.gistPublishService = gistPublishService; + this.notificationService = notificationService; this.usageTracker = usageTracker; FileName = VisualStudio.Services.GetFileNameFromActiveDocument() ?? Resources.DefaultGistFileName; SelectedText = selectedTextProvider.GetSelectedText(); - var modelService = modelServiceFactory.CreateBlocking(connection); + var canCreateGist = this.WhenAny( + x => x.FileName, + fileName => !String.IsNullOrEmpty(fileName.Value)); + + CreateGist = ReactiveCommand.CreateAsyncObservable(canCreateGist, OnCreateGist); + } + + public async Task InitializeAsync(IConnection connection) + { + var modelService = await modelServiceFactory.CreateAsync(connection); apiClient = modelService.ApiClient; // This class is only instantiated after we are logged into to a github account, so we should be safe to grab the first one here as the defaut. @@ -78,12 +68,6 @@ public GistCreationViewModel( .Select(a => a.First()) .ObserveOn(RxApp.MainThreadScheduler) .ToProperty(this, vm => vm.Account); - - var canCreateGist = this.WhenAny( - x => x.FileName, - fileName => !String.IsNullOrEmpty(fileName.Value)); - - CreateGist = ReactiveCommand.CreateAsyncObservable(canCreateGist, OnCreateGist); } IObservable OnCreateGist(object unused) @@ -110,6 +94,8 @@ IObservable OnCreateGist(object unused) }); } + public string Title => Resources.CreateGistTitle; + public IReactiveCommand CreateGist { get; } public IAccount Account @@ -145,6 +131,6 @@ public string FileName set { this.RaiseAndSetIfChanged(ref fileName, value); } } - public override IObservable Done => CreateGist.Where(x => x != null).SelectUnit(); + public IObservable Done => CreateGist.Where(x => x != null); } } diff --git a/src/GitHub.App/ViewModels/Dialog/GitHubDialogWindowViewModel.cs b/src/GitHub.App/ViewModels/Dialog/GitHubDialogWindowViewModel.cs new file mode 100644 index 0000000000..e1a746d7d0 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/GitHubDialogWindowViewModel.cs @@ -0,0 +1,101 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the top-level view model for the GitHub dialog. + /// + [Export(typeof(IGitHubDialogWindowViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class GitHubDialogWindowViewModel : ViewModelBase, IGitHubDialogWindowViewModel + { + readonly IViewViewModelFactory factory; + readonly Lazy connectionManager; + IDialogContentViewModel content; + Subject done = new Subject(); + IDisposable subscription; + + [ImportingConstructor] + public GitHubDialogWindowViewModel( + IViewViewModelFactory factory, + Lazy connectionManager) + { + this.factory = factory; + this.connectionManager = connectionManager; + } + + /// + public IDialogContentViewModel Content + { + get { return content; } + private set { this.RaiseAndSetIfChanged(ref content, value); } + } + + /// + public IObservable Done => done; + + /// + public void Dispose() + { + subscription?.Dispose(); + subscription = null; + } + + /// + public void Start(IDialogContentViewModel viewModel) + { + subscription?.Dispose(); + Content = viewModel; + subscription = viewModel.Done.Subscribe(done); + } + + /// + public async Task StartWithConnection(T viewModel) + where T : IDialogContentViewModel, IConnectionInitializedViewModel + { + var connections = await connectionManager.Value.GetLoadedConnections(); + var connection = connections.FirstOrDefault(x => x.IsLoggedIn); + + if (connection == null) + { + var login = CreateLoginViewModel(); + + 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; + } + else + { + await viewModel.InitializeAsync(connection); + Start(viewModel); + } + } + + ILoginViewModel CreateLoginViewModel() + { + return factory.CreateViewModel(); + } + } +} diff --git a/src/GitHub.App/ViewModels/TwoFactorDialogViewModel.cs b/src/GitHub.App/ViewModels/Dialog/Login2FaViewModel.cs similarity index 83% rename from src/GitHub.App/ViewModels/TwoFactorDialogViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/Login2FaViewModel.cs index 59ea61109d..5d5492769a 100644 --- a/src/GitHub.App/ViewModels/TwoFactorDialogViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/Login2FaViewModel.cs @@ -1,44 +1,35 @@ using System; using System.ComponentModel.Composition; -using System.Diagnostics; using System.Globalization; -using System.Reactive; using System.Reactive.Linq; using GitHub.App; using GitHub.Authentication; -using GitHub.Exports; using GitHub.Extensions; using GitHub.Info; -using GitHub.Logging; using GitHub.Services; using GitHub.Validation; using Octokit; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - [ExportViewModel(ViewType = UIViewType.TwoFactor)] + [Export(typeof(ILogin2FaViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class TwoFactorDialogViewModel : DialogViewModelBase, ITwoFactorDialogViewModel + public class Login2FaViewModel : ViewModelBase, ILogin2FaViewModel { bool isAuthenticationCodeSent; bool invalidAuthenticationCode; string authenticationCode; TwoFactorType twoFactorType; + bool isBusy; readonly ObservableAsPropertyHelper description; readonly ObservableAsPropertyHelper isSms; readonly ObservableAsPropertyHelper showErrorMessage; [ImportingConstructor] - public TwoFactorDialogViewModel( - IVisualStudioBrowser browser, - IDelegatingTwoFactorChallengeHandler twoFactorChallengeHandler) + public Login2FaViewModel(IVisualStudioBrowser browser) { Guard.ArgumentNotNull(browser, nameof(browser)); - Guard.ArgumentNotNull(twoFactorChallengeHandler, nameof(twoFactorChallengeHandler)); - - Title = Resources.TwoFactorTitle; - twoFactorChallengeHandler.SetViewModel(this); var canVerify = this.WhenAny( x => x.AuthenticationCode, @@ -46,7 +37,6 @@ public TwoFactorDialogViewModel( (code, busy) => !string.IsNullOrEmpty(code.Value) && code.Value.Length == 6 && !busy.Value); OkCommand = ReactiveCommand.Create(canVerify); - Cancel.Subscribe(_ => TwoFactorType = TwoFactorType.None); NavigateLearnMore = ReactiveCommand.Create(); NavigateLearnMore.Subscribe(x => browser.OpenUrl(GitHubUrls.TwoFactorLearnMore)); //TODO: ShowHelpCommand.Subscribe(x => browser.OpenUrl(twoFactorHelpUri)); @@ -76,10 +66,6 @@ public TwoFactorDialogViewModel( }) .ToProperty(this, x => x.Description); - isShowing = this.WhenAny(x => x.TwoFactorType, x => x.Value) - .Select(factorType => factorType != TwoFactorType.None) - .ToProperty(this, x => x.IsShowing); - isSms = this.WhenAny(x => x.TwoFactorType, x => x.Value) .Select(factorType => factorType == TwoFactorType.Sms) .ToProperty(this, x => x.IsSms); @@ -112,16 +98,27 @@ public IObservable Show(UserError userError) var resend = ResendCodeCommand.Select(_ => RecoveryOptionResult.RetryOperation) .Select(_ => TwoFactorChallengeResult.RequestResendCode) .Do(_ => IsAuthenticationCodeSent = true); - var cancel = Cancel.Select(_ => default(TwoFactorChallengeResult)); + var cancel = this.WhenAnyValue(x => x.TwoFactorType) + .Skip(1) + .Where(x => x == TwoFactorType.None) + .Select(_ => default(TwoFactorChallengeResult)); return Observable.Merge(ok, cancel, resend).Take(1); } + public string Title => Resources.TwoFactorTitle; + public TwoFactorType TwoFactorType { get { return twoFactorType; } private set { this.RaiseAndSetIfChanged(ref twoFactorType, value); } } + public bool IsBusy + { + get { return isBusy; } + private set { this.RaiseAndSetIfChanged(ref isBusy, value); } + } + public bool IsSms { get { return isSms.Value; } } public bool IsAuthenticationCodeSent @@ -141,6 +138,7 @@ public string AuthenticationCode set { this.RaiseAndSetIfChanged(ref authenticationCode, value); } } + public IObservable Done => null; public ReactiveCommand OkCommand { get; private set; } public ReactiveCommand NavigateLearnMore { get; private set; } public ReactiveCommand ResendCodeCommand { get; private set; } @@ -157,6 +155,6 @@ public bool ShowErrorMessage get { return showErrorMessage.Value; } } - public override IObservable Done => Observable.Never(); + public void Cancel() => TwoFactorType = TwoFactorType.None; } } diff --git a/src/GitHub.App/ViewModels/LoginControlViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginCredentialsViewModel.cs similarity index 50% rename from src/GitHub.App/ViewModels/LoginControlViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/LoginCredentialsViewModel.cs index b62b825021..6e228dec27 100644 --- a/src/GitHub.App/ViewModels/LoginControlViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/LoginCredentialsViewModel.cs @@ -1,28 +1,23 @@ using System; using System.ComponentModel.Composition; -using System.Reactive; using System.Reactive.Linq; using GitHub.App; -using GitHub.Authentication; -using GitHub.Exports; -using GitHub.Extensions.Reactive; using GitHub.Primitives; using GitHub.Services; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - [ExportViewModel(ViewType = UIViewType.Login)] + [Export(typeof(ILoginCredentialsViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class LoginControlViewModel : DialogViewModelBase, ILoginControlViewModel + public class LoginCredentialsViewModel : ViewModelBase, ILoginCredentialsViewModel { [ImportingConstructor] - public LoginControlViewModel( + public LoginCredentialsViewModel( IConnectionManager connectionManager, ILoginToGitHubViewModel loginToGitHubViewModel, ILoginToGitHubForEnterpriseViewModel loginToGitHubEnterpriseViewModel) { - Title = Resources.LoginTitle; ConnectionManager = connectionManager; GitHubLogin = loginToGitHubViewModel; EnterpriseLogin = loginToGitHubEnterpriseViewModel; @@ -36,31 +31,16 @@ public LoginControlViewModel( UpdateLoginMode(); connectionManager.Connections.CollectionChanged += (_, __) => UpdateLoginMode(); - AuthenticationResults = Observable.Merge( + Done = Observable.Merge( loginToGitHubViewModel.Login, - EnterpriseLogin.Login); + EnterpriseLogin.Login) + .Where(x => x != null); } - ILoginToGitHubViewModel github; - public ILoginToGitHubViewModel GitHubLogin - { - get { return github; } - set { this.RaiseAndSetIfChanged(ref github, value); } - } - - ILoginToGitHubForEnterpriseViewModel githubEnterprise; - public ILoginToGitHubForEnterpriseViewModel EnterpriseLogin - { - get { return githubEnterprise; } - set { this.RaiseAndSetIfChanged(ref githubEnterprise, value); } - } - - IConnectionManager connectionManager; - public IConnectionManager ConnectionManager - { - get { return connectionManager; } - set { this.RaiseAndSetIfChanged(ref connectionManager, value); } - } + public string Title => Resources.LoginTitle; + public ILoginToGitHubViewModel GitHubLogin { get; } + public ILoginToGitHubForEnterpriseViewModel EnterpriseLogin { get; } + public IConnectionManager ConnectionManager { get; } LoginMode loginMode; public LoginMode LoginMode @@ -72,21 +52,19 @@ public LoginMode LoginMode readonly ObservableAsPropertyHelper isLoginInProgress; public bool IsLoginInProgress { get { return isLoginInProgress.Value; } } - public IObservable AuthenticationResults { get; private set; } - - public override IObservable Done - { - get { return AuthenticationResults.Where(x => x == AuthenticationResult.Success).SelectUnit(); } - } + public IObservable Done { get; } void UpdateLoginMode() { var result = LoginMode.DotComOrEnterprise; - foreach (var connection in connectionManager.Connections) + foreach (var connection in ConnectionManager.Connections) { - result &= ~((connection.HostAddress == HostAddress.GitHubDotComHostAddress) ? - LoginMode.DotComOnly : LoginMode.EnterpriseOnly); + if (connection.IsLoggedIn) + { + result &= ~((connection.HostAddress == HostAddress.GitHubDotComHostAddress) ? + LoginMode.DotComOnly : LoginMode.EnterpriseOnly); + } } LoginMode = result; diff --git a/src/GitHub.App/ViewModels/LoginTabViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginTabViewModel.cs similarity index 66% rename from src/GitHub.App/ViewModels/LoginTabViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/LoginTabViewModel.cs index 0a98ffd235..e82181cf5c 100644 --- a/src/GitHub.App/ViewModels/LoginTabViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/LoginTabViewModel.cs @@ -1,8 +1,9 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Reactive; using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; +using System.Threading; using System.Threading.Tasks; using GitHub.App; using GitHub.Authentication; @@ -10,13 +11,14 @@ using GitHub.Extensions.Reactive; using GitHub.Info; using GitHub.Logging; +using GitHub.Models; using GitHub.Primitives; using GitHub.Services; using GitHub.Validation; using ReactiveUI; using Serilog; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] public abstract class LoginTabViewModel : ReactiveObject @@ -42,8 +44,7 @@ protected LoginTabViewModel(IConnectionManager connectionManager, IVisualStudioB x => x.PasswordValidator.ValidationResult.IsValid, (x, y) => x.Value && y.Value).ToProperty(this, x => x.CanLogin); - Login = ReactiveCommand.CreateAsyncObservable(this.WhenAny(x => x.CanLogin, x => x.Value), LogIn); - + Login = ReactiveCommand.CreateAsyncTask(this.WhenAny(x => x.CanLogin, x => x.Value), LogIn); Login.ThrownExceptions.Subscribe(ex => { if (ex.IsCriticalException()) return; @@ -79,7 +80,7 @@ protected LoginTabViewModel(IConnectionManager connectionManager, IVisualStudioB protected abstract Uri BaseUri { get; } public IReactiveCommand SignUp { get; } - public IReactiveCommand Login { get; } + public IReactiveCommand Login { get; } public IReactiveCommand Reset { get; } public IRecoveryCommand NavigateForgotPassword { get; } @@ -132,55 +133,18 @@ public UserError Error set { this.RaiseAndSetIfChanged(ref error, value); } } - protected abstract IObservable LogIn(object args); + protected abstract Task LogIn(object args); - protected IObservable LogInToHost(HostAddress hostAddress) + protected async Task LogInToHost(HostAddress hostAddress) { Guard.ArgumentNotNull(hostAddress, nameof(hostAddress)); - return Observable.Defer(() => - { - return hostAddress != null ? - ConnectionManager.LogIn(hostAddress, UsernameOrEmail, Password) - .ToObservable() - .Select(_ => AuthenticationResult.Success) - : Observable.Return(AuthenticationResult.CredentialFailure); - }) - .ObserveOn(RxApp.MainThreadScheduler) - .Do(authResult => { - switch (authResult) - { - case AuthenticationResult.CredentialFailure: - Error = new UserError( - Resources.LoginFailedText, - Resources.LoginFailedMessage, - new[] { NavigateForgotPassword }); - break; - case AuthenticationResult.VerificationFailure: - break; - case AuthenticationResult.EnterpriseServerNotFound: - Error = new UserError(Resources.CouldNotConnectToGitHub); - break; - } - }) - .SelectMany(authResult => + if (await ConnectionManager.GetConnection(hostAddress) != null) { - switch (authResult) - { - case AuthenticationResult.CredentialFailure: - case AuthenticationResult.EnterpriseServerNotFound: - case AuthenticationResult.VerificationFailure: - Password = ""; - return Observable.FromAsync(PasswordValidator.ResetAsync) - .Select(_ => AuthenticationResult.CredentialFailure); - case AuthenticationResult.Success: - return Reset.ExecuteAsync() - .ContinueAfter(() => Observable.Return(AuthenticationResult.Success)); - default: - return Observable.Throw( - new InvalidOperationException("Unknown EnterpriseLoginResult: " + authResult)); - } - }); + await ConnectionManager.LogOut(hostAddress); + } + + return await ConnectionManager.LogIn(hostAddress, UsernameOrEmail, Password); } async Task Clear() diff --git a/src/GitHub.App/ViewModels/LoginToGitHubForEnterpriseViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubForEnterpriseViewModel.cs similarity index 94% rename from src/GitHub.App/ViewModels/LoginToGitHubForEnterpriseViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/LoginToGitHubForEnterpriseViewModel.cs index 8ded402ec9..75f151f7e2 100644 --- a/src/GitHub.App/ViewModels/LoginToGitHubForEnterpriseViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubForEnterpriseViewModel.cs @@ -4,15 +4,15 @@ using System.Reactive.Linq; using System.Threading.Tasks; using GitHub.App; -using GitHub.Authentication; using GitHub.Extensions; using GitHub.Info; using GitHub.Primitives; using GitHub.Services; using GitHub.Validation; using ReactiveUI; +using IConnection = GitHub.Models.IConnection; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { [Export(typeof(ILoginToGitHubForEnterpriseViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] @@ -41,11 +41,10 @@ public LoginToGitHubForEnterpriseViewModel(IConnectionManager connectionManager, { browser.OpenUrl(GitHubUrls.LearnMore); return Observable.Return(Unit.Default); - }); } - protected override IObservable LogIn(object args) + protected override Task LogIn(object args) { return LogInToHost(HostAddress.Create(EnterpriseUrl)); } diff --git a/src/GitHub.App/ViewModels/LoginToGitHubViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubViewModel.cs similarity index 88% rename from src/GitHub.App/ViewModels/LoginToGitHubViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/LoginToGitHubViewModel.cs index 24fe832b6a..9024618c7e 100644 --- a/src/GitHub.App/ViewModels/LoginToGitHubViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/LoginToGitHubViewModel.cs @@ -2,13 +2,15 @@ using System.ComponentModel.Composition; using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; using GitHub.Authentication; using GitHub.Info; +using GitHub.Models; using GitHub.Primitives; using GitHub.Services; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { [Export(typeof(ILoginToGitHubViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] @@ -32,7 +34,7 @@ public LoginToGitHubViewModel(IConnectionManager connectionManager, IVisualStudi protected override Uri BaseUri { get; } - protected override IObservable LogIn(object args) + protected override Task LogIn(object args) { return LogInToHost(HostAddress.GitHubDotComHostAddress); } diff --git a/src/GitHub.App/ViewModels/Dialog/LoginViewModel.cs b/src/GitHub.App/ViewModels/Dialog/LoginViewModel.cs new file mode 100644 index 0000000000..2ed1c4f167 --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/LoginViewModel.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using GitHub.Authentication; +using Octokit; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the Login dialog content. + /// + [Export(typeof(ILoginViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class LoginViewModel : PagedDialogViewModelBase, ILoginViewModel + { + [ImportingConstructor] + public LoginViewModel( + ILoginCredentialsViewModel credentials, + ILogin2FaViewModel twoFactor, + IDelegatingTwoFactorChallengeHandler twoFactorHandler) + { + twoFactorHandler.SetViewModel(twoFactor); + + Content = credentials; + Done = credentials.Done; + + twoFactor.WhenAnyValue(x => x.TwoFactorType) + .Subscribe(x => + { + Content = x == TwoFactorType.None ? + (IDialogContentViewModel)credentials : + twoFactor; + }); + } + + /// + public override IObservable Done { get; } + } +} diff --git a/src/GitHub.App/ViewModels/Dialog/PagedDialogViewModelBase.cs b/src/GitHub.App/ViewModels/Dialog/PagedDialogViewModelBase.cs new file mode 100644 index 0000000000..621d747eab --- /dev/null +++ b/src/GitHub.App/ViewModels/Dialog/PagedDialogViewModelBase.cs @@ -0,0 +1,38 @@ +using System; +using ReactiveUI; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Base class for view models representing a multi-page dialog. + /// + public abstract class PagedDialogViewModelBase : ViewModelBase, IDialogContentViewModel + { + readonly ObservableAsPropertyHelper title; + IDialogContentViewModel content; + + /// + /// Initializes a new instance of the class. + /// + protected PagedDialogViewModelBase() + { + title = this.WhenAny(x => x.Content, x => x.Value?.Title ?? "GitHub") + .ToProperty(this, x => x.Title); + } + + /// + /// Gets the current page being displayed in the dialog. + /// + public IDialogContentViewModel Content + { + get { return content; } + protected set { this.RaiseAndSetIfChanged(ref content, value); } + } + + /// + public abstract IObservable Done { get; } + + /// + public string Title => title.Value; + } +} diff --git a/src/GitHub.App/ViewModels/RepositoryCloneViewModel.cs b/src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs similarity index 89% rename from src/GitHub.App/ViewModels/RepositoryCloneViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs index a6442f476f..f959091ee0 100644 --- a/src/GitHub.App/ViewModels/RepositoryCloneViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/RepositoryCloneViewModel.cs @@ -7,35 +7,31 @@ 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.Exports; using GitHub.Extensions; -using GitHub.Extensions.Reactive; using GitHub.Factories; using GitHub.Logging; using GitHub.Models; using GitHub.Services; -using GitHub.UI; using GitHub.Validation; using ReactiveUI; using Rothko; using Serilog; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - [ExportViewModel(ViewType=UIViewType.Clone)] + [Export(typeof(IRepositoryCloneViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class RepositoryCloneViewModel : DialogViewModelBase, IRepositoryCloneViewModel + public class RepositoryCloneViewModel : ViewModelBase, IRepositoryCloneViewModel { static readonly ILogger log = LogManager.ForContext(); - readonly IConnection connection; readonly IModelServiceFactory modelServiceFactory; readonly IOperatingSystem operatingSystem; readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); - IModelService modelService; bool noRepositoriesFound; readonly ObservableAsPropertyHelper canClone; string baseRepositoryPath; @@ -43,31 +39,17 @@ public class RepositoryCloneViewModel : DialogViewModelBase, IRepositoryCloneVie [ImportingConstructor] public RepositoryCloneViewModel( - IGlobalConnection connection, IModelServiceFactory modelServiceFactory, IRepositoryCloneService cloneService, IOperatingSystem operatingSystem) - : this(connection.Get(), modelServiceFactory, cloneService, operatingSystem) { - } - - public RepositoryCloneViewModel( - IConnection connection, - IModelServiceFactory modelServiceFactory, - IRepositoryCloneService cloneService, - IOperatingSystem operatingSystem) - { - Guard.ArgumentNotNull(connection, nameof(connection)); Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); Guard.ArgumentNotNull(cloneService, nameof(cloneService)); Guard.ArgumentNotNull(operatingSystem, nameof(operatingSystem)); - this.connection = connection; this.modelServiceFactory = modelServiceFactory; this.operatingSystem = operatingSystem; - Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, connection.HostAddress.Title); - Repositories = new TrackingCollection(); repositories.ProcessingDelay = TimeSpan.Zero; repositories.Comparer = OrderedComparer.OrderBy(x => x.Owner).ThenBy(x => x.Name).Compare; @@ -120,6 +102,7 @@ public RepositoryCloneViewModel( (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) @@ -128,14 +111,11 @@ public RepositoryCloneViewModel( NoRepositoriesFound = true; } - public override void Initialize(ViewWithData data) + public async Task InitializeAsync(IConnection connection) { - base.Initialize(data); + var modelService = await modelServiceFactory.CreateAsync(connection); - if (modelService == null) - { - modelService = modelServiceFactory.CreateBlocking(connection); - } + Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, connection.HostAddress.Title); IsBusy = true; modelService.GetRepositories(repositories); @@ -208,6 +188,11 @@ IObservable ShowBrowseForDirectoryDialog() }, RxApp.MainThreadScheduler); } + /// + /// Gets the title for the dialog. + /// + public string Title { get; private set; } + /// /// Path to clone repositories into /// @@ -222,6 +207,13 @@ public string BaseRepositoryPath /// 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 { @@ -283,6 +275,6 @@ public ReactivePropertyValidator BaseRepositoryPathValidator private set; } - public override IObservable Done => CloneCommand.SelectUnit(); + public IObservable Done { get; } } } diff --git a/src/GitHub.App/ViewModels/RepositoryCreationViewModel.cs b/src/GitHub.App/ViewModels/Dialog/RepositoryCreationViewModel.cs similarity index 93% rename from src/GitHub.App/ViewModels/RepositoryCreationViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/RepositoryCreationViewModel.cs index 28351a2239..673a1147fc 100644 --- a/src/GitHub.App/ViewModels/RepositoryCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/RepositoryCreationViewModel.cs @@ -7,10 +7,10 @@ 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.Exports; using GitHub.Extensions; using GitHub.Extensions.Reactive; using GitHub.Factories; @@ -25,66 +25,44 @@ using Serilog; using IConnection = GitHub.Models.IConnection; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - [ExportViewModel(ViewType=UIViewType.Create)] + [Export(typeof(IRepositoryCreationViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public class RepositoryCreationViewModel : RepositoryFormViewModel, IRepositoryCreationViewModel { static readonly ILogger log = LogManager.ForContext(); readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); - readonly ObservableAsPropertyHelper> accounts; - readonly IModelService modelService; + readonly IModelServiceFactory modelServiceFactory; readonly IRepositoryCreationService repositoryCreationService; readonly ObservableAsPropertyHelper isCreating; readonly ObservableAsPropertyHelper canKeepPrivate; readonly IOperatingSystem operatingSystem; readonly IUsageTracker usageTracker; + ObservableAsPropertyHelper> accounts; + IModelService modelService; [ImportingConstructor] public RepositoryCreationViewModel( - IGlobalConnection connection, IModelServiceFactory modelServiceFactory, IOperatingSystem operatingSystem, IRepositoryCreationService repositoryCreationService, IUsageTracker usageTracker) - : this(connection.Get(), modelServiceFactory, operatingSystem, repositoryCreationService, usageTracker) { - } - - public RepositoryCreationViewModel( - IConnection connection, - IModelServiceFactory modelServiceFactory, - IOperatingSystem operatingSystem, - IRepositoryCreationService repositoryCreationService, - IUsageTracker usageTracker) - { - Guard.ArgumentNotNull(connection, nameof(connection)); Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); Guard.ArgumentNotNull(operatingSystem, nameof(operatingSystem)); Guard.ArgumentNotNull(repositoryCreationService, nameof(repositoryCreationService)); Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + this.modelServiceFactory = modelServiceFactory; this.operatingSystem = operatingSystem; this.repositoryCreationService = repositoryCreationService; this.usageTracker = usageTracker; - Title = string.Format(CultureInfo.CurrentCulture, Resources.CreateTitle, connection.HostAddress.Title); SelectedGitIgnoreTemplate = GitIgnoreItem.None; SelectedLicense = LicenseItem.None; - modelService = modelServiceFactory.CreateBlocking(connection); - - accounts = modelService.GetAccounts() - .ObserveOn(RxApp.MainThreadScheduler) - .ToProperty(this, vm => vm.Accounts, initialValue: new ReadOnlyCollection(new IAccount[] {})); - - this.WhenAny(x => x.Accounts, x => x.Value) - .Select(accts => accts?.FirstOrDefault()) - .WhereNotNull() - .Subscribe(a => SelectedAccount = a); - browseForDirectoryCommand.Subscribe(_ => ShowBrowseForDirectoryDialog()); BaseRepositoryPathValidator = ReactivePropertyValidator.ForObservable(this.WhenAny(x => x.BaseRepositoryPath, x => x.Value)) @@ -123,24 +101,11 @@ public RepositoryCreationViewModel( isCreating = CreateRepository.IsExecuting .ToProperty(this, x => x.IsCreating); - GitIgnoreTemplates = TrackingCollection.CreateListenerCollectionAndRun( - modelService.GetGitIgnoreTemplates(), - new[] { GitIgnoreItem.None }, - OrderedComparer.OrderByDescending(item => GitIgnoreItem.IsRecommended(item.Name)).Compare, - x => - { - if (x.Name.Equals("VisualStudio", StringComparison.OrdinalIgnoreCase)) - SelectedGitIgnoreTemplate = x; - }); - - Licenses = TrackingCollection.CreateListenerCollectionAndRun( - modelService.GetLicenses(), - new[] { LicenseItem.None }, - OrderedComparer.OrderByDescending(item => LicenseItem.IsRecommended(item.Name)).Compare); - BaseRepositoryPath = repositoryCreationService.DefaultClonePath; } + public string Title { get; private set; } + string baseRepositoryPath; /// /// Path to clone repositories into @@ -206,7 +171,38 @@ public LicenseItem SelectedLicense /// public IReactiveCommand CreateRepository { get; private set; } - public override IObservable Done => CreateRepository; + public IObservable Done => CreateRepository.Select(_ => (object)null); + + public async Task InitializeAsync(IConnection connection) + { + modelService = await modelServiceFactory.CreateAsync(connection); + + Title = string.Format(CultureInfo.CurrentCulture, Resources.CreateTitle, connection.HostAddress.Title); + + accounts = modelService.GetAccounts() + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, vm => vm.Accounts, initialValue: new ReadOnlyCollection(new IAccount[] { })); + + this.WhenAny(x => x.Accounts, x => x.Value) + .Select(accts => accts?.FirstOrDefault()) + .WhereNotNull() + .Subscribe(a => SelectedAccount = a); + + GitIgnoreTemplates = TrackingCollection.CreateListenerCollectionAndRun( + modelService.GetGitIgnoreTemplates(), + new[] { GitIgnoreItem.None }, + OrderedComparer.OrderByDescending(item => GitIgnoreItem.IsRecommended(item.Name)).Compare, + x => + { + if (x.Name.Equals("VisualStudio", StringComparison.OrdinalIgnoreCase)) + SelectedGitIgnoreTemplate = x; + }); + + Licenses = TrackingCollection.CreateListenerCollectionAndRun( + modelService.GetLicenses(), + new[] { LicenseItem.None }, + OrderedComparer.OrderByDescending(item => LicenseItem.IsRecommended(item.Name)).Compare); + } protected override NewRepository GatherRepositoryInfo() { diff --git a/src/GitHub.App/ViewModels/StartPageCloneViewModel.cs b/src/GitHub.App/ViewModels/Dialog/RepositoryRecloneViewModel.cs similarity index 86% rename from src/GitHub.App/ViewModels/StartPageCloneViewModel.cs rename to src/GitHub.App/ViewModels/Dialog/RepositoryRecloneViewModel.cs index 4947b36a6a..e74b5121b2 100644 --- a/src/GitHub.App/ViewModels/StartPageCloneViewModel.cs +++ b/src/GitHub.App/ViewModels/Dialog/RepositoryRecloneViewModel.cs @@ -1,15 +1,13 @@ using System; using System.ComponentModel.Composition; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Reactive; using System.Reactive.Linq; +using System.Threading.Tasks; using System.Windows.Input; using GitHub.App; -using GitHub.Exports; using GitHub.Extensions; -using GitHub.Extensions.Reactive; using GitHub.Logging; using GitHub.Models; using GitHub.Services; @@ -18,13 +16,13 @@ using Rothko; using Serilog; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - [ExportViewModel(ViewType=UIViewType.StartPageClone)] + [Export(typeof(IRepositoryRecloneViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class StartPageCloneViewModel : DialogViewModelBase, IBaseCloneViewModel + public class RepositoryRecloneViewModel : ViewModelBase, IRepositoryRecloneViewModel { - static readonly ILogger log = LogManager.ForContext(); + static readonly ILogger log = LogManager.ForContext(); readonly IOperatingSystem operatingSystem; readonly ReactiveCommand browseForDirectoryCommand = ReactiveCommand.Create(); @@ -32,27 +30,15 @@ public class StartPageCloneViewModel : DialogViewModelBase, IBaseCloneViewModel string baseRepositoryPath; [ImportingConstructor] - public StartPageCloneViewModel( - IGlobalConnection connection, + public RepositoryRecloneViewModel( IRepositoryCloneService cloneService, IOperatingSystem operatingSystem) - : this(connection.Get(), cloneService, operatingSystem) { - } - - public StartPageCloneViewModel( - IConnection connection, - IRepositoryCloneService cloneService, - IOperatingSystem operatingSystem) - { - Guard.ArgumentNotNull(connection, nameof(connection)); Guard.ArgumentNotNull(cloneService, nameof(cloneService)); Guard.ArgumentNotNull(operatingSystem, nameof(operatingSystem)); this.operatingSystem = operatingSystem; - Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, connection.HostAddress.Title); - var baseRepositoryPath = this.WhenAny( x => x.BaseRepositoryPath, x => x.SelectedRepository, @@ -78,6 +64,12 @@ public StartPageCloneViewModel( BaseRepositoryPath = cloneService.DefaultClonePath; } + public Task InitializeAsync(IConnection connection) + { + Title = string.Format(CultureInfo.CurrentCulture, Resources.CloneTitle, connection.HostAddress.Title); + return Task.CompletedTask; + } + bool IsAlreadyRepoAtPath(string path) { Guard.ArgumentNotNull(path, nameof(path)); @@ -120,6 +112,11 @@ IObservable ShowBrowseForDirectoryDialog() }, RxApp.MainThreadScheduler); } + /// + /// Gets the dialog title. + /// + public string Title { get; private set; } + /// /// Path to clone repositories into /// @@ -160,6 +157,6 @@ public ReactivePropertyValidator BaseRepositoryPathValidator private set; } - public override IObservable Done => CloneCommand.SelectUnit(); + public IObservable Done => CloneCommand.Select(_ => BaseRepositoryPath); } } diff --git a/src/GitHub.App/ViewModels/DialogViewModelBase.cs b/src/GitHub.App/ViewModels/DialogViewModelBase.cs deleted file mode 100644 index 7575de4b0e..0000000000 --- a/src/GitHub.App/ViewModels/DialogViewModelBase.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Reactive; -using GitHub.Extensions.Reactive; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// Base class for view models that can be dismissed, such as dialogs. - /// - public abstract class DialogViewModelBase : ViewModelBase, IDialogViewModel, IHasBusy - { - protected ObservableAsPropertyHelper isShowing; - string title; - bool isBusy; - - /// - /// Initializes a new instance of the class. - /// - protected DialogViewModelBase() - { - Cancel = ReactiveCommand.Create(); - } - - /// - public abstract IObservable Done { get; } - - /// - public ReactiveCommand Cancel { get; } - - /// - public string Title - { - get { return title; } - protected set { this.RaiseAndSetIfChanged(ref title, value); } - } - - /// - public bool IsShowing { get { return isShowing?.Value ?? true; } } - - /// - public bool IsBusy - { - get { return isBusy; } - protected set { this.RaiseAndSetIfChanged(ref isBusy, value); } - } - - /// - IObservable IHasCancel.Cancel => Cancel.SelectUnit(); - } -} diff --git a/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs new file mode 100644 index 0000000000..6e3f524f74 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/GitHubPaneViewModel.cs @@ -0,0 +1,410 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Info; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using GitHub.VisualStudio; +using ReactiveUI; +using OleMenuCommand = Microsoft.VisualStudio.Shell.OleMenuCommand; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// The view model for the GitHub Pane. + /// + [Export(typeof(IGitHubPaneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class GitHubPaneViewModel : ViewModelBase, IGitHubPaneViewModel, IDisposable + { + static readonly Regex pullUri = CreateRoute("/:owner/:repo/pull/:number"); + + readonly IViewViewModelFactory viewModelFactory; + readonly ISimpleApiClientFactory apiClientFactory; + readonly IConnectionManager connectionManager; + readonly ITeamExplorerServiceHolder teServiceHolder; + readonly IVisualStudioBrowser browser; + readonly IUsageTracker usageTracker; + readonly INavigationViewModel navigator; + readonly ILoggedOutViewModel loggedOut; + readonly INotAGitHubRepositoryViewModel notAGitHubRepository; + readonly INotAGitRepositoryViewModel notAGitRepository; + readonly SemaphoreSlim navigating = new SemaphoreSlim(1); + readonly ObservableAsPropertyHelper contentOverride; + readonly ObservableAsPropertyHelper isSearchEnabled; + readonly ObservableAsPropertyHelper title; + readonly ReactiveCommand refresh; + readonly ReactiveCommand showPullRequests; + readonly ReactiveCommand openInBrowser; + IViewModel content; + ILocalRepositoryModel localRepository; + string searchQuery; + + [ImportingConstructor] + public GitHubPaneViewModel( + IViewViewModelFactory viewModelFactory, + ISimpleApiClientFactory apiClientFactory, + IConnectionManager connectionManager, + ITeamExplorerServiceHolder teServiceHolder, + IVisualStudioBrowser browser, + IUsageTracker usageTracker, + INavigationViewModel navigator, + ILoggedOutViewModel loggedOut, + INotAGitHubRepositoryViewModel notAGitHubRepository, + INotAGitRepositoryViewModel notAGitRepository) + { + Guard.ArgumentNotNull(viewModelFactory, nameof(viewModelFactory)); + Guard.ArgumentNotNull(apiClientFactory, nameof(apiClientFactory)); + Guard.ArgumentNotNull(connectionManager, nameof(connectionManager)); + Guard.ArgumentNotNull(teServiceHolder, nameof(teServiceHolder)); + Guard.ArgumentNotNull(browser, nameof(browser)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + Guard.ArgumentNotNull(navigator, nameof(navigator)); + Guard.ArgumentNotNull(loggedOut, nameof(loggedOut)); + Guard.ArgumentNotNull(notAGitHubRepository, nameof(notAGitHubRepository)); + Guard.ArgumentNotNull(notAGitRepository, nameof(notAGitRepository)); + + this.viewModelFactory = viewModelFactory; + this.apiClientFactory = apiClientFactory; + this.connectionManager = connectionManager; + this.teServiceHolder = teServiceHolder; + this.browser = browser; + this.usageTracker = usageTracker; + this.navigator = navigator; + this.loggedOut = loggedOut; + this.notAGitHubRepository = notAGitHubRepository; + this.notAGitRepository = notAGitRepository; + + var contentAndNavigatorContent = Observable.CombineLatest( + this.WhenAnyValue(x => x.Content), + navigator.WhenAnyValue(x => x.Content), + (c, nc) => new { Content = c, NavigatorContent = nc }); + + contentOverride = contentAndNavigatorContent + .SelectMany(x => + { + if (x.Content == null) return Observable.Return(ContentOverride.Spinner); + else if (x.Content == navigator && x.NavigatorContent != null) + { + return x.NavigatorContent.WhenAnyValue( + y => y.IsLoading, + y => y.Error, + (l, e) => + { + if (l) return ContentOverride.Spinner; + if (e != null) return ContentOverride.Error; + else return ContentOverride.None; + }); + } + else return Observable.Return(ContentOverride.None); + }) + .ToProperty(this, x => x.ContentOverride); + + // Returns navigator.Content if Content == navigator, otherwise null. + var currentPage = contentAndNavigatorContent + .Select(x => x.Content == navigator ? x.NavigatorContent : null); + + title = currentPage + .SelectMany(x => x?.WhenAnyValue(y => y.Title) ?? Observable.Return(null)) + .Select(x => x ?? "GitHub") + .ToProperty(this, x => x.Title); + + isSearchEnabled = currentPage + .Select(x => x is ISearchablePageViewModel) + .ToProperty(this, x => x.IsSearchEnabled); + + refresh = ReactiveCommand.CreateAsyncTask( + currentPage.SelectMany(x => x?.WhenAnyValue( + y => y.IsLoading, + y => y.IsBusy, + (loading, busy) => !loading && !busy) + ?? Observable.Return(false)), + _ => navigator.Content.Refresh()); + refresh.ThrownExceptions.Subscribe(); + + showPullRequests = ReactiveCommand.CreateAsyncTask( + this.WhenAny(x => x.Content, x => x.Value == navigator), + _ => ShowPullRequests()); + + openInBrowser = ReactiveCommand.Create(currentPage.Select(x => x is IOpenInBrowser)); + openInBrowser.Subscribe(_ => + { + var url = ((IOpenInBrowser)navigator.Content).WebUrl; + if (url != null) browser.OpenUrl(url); + }); + + navigator.WhenAnyObservable(x => x.Content.NavigationRequested) + .Subscribe(x => NavigateTo(x).Forget()); + + this.WhenAnyValue(x => x.SearchQuery) + .Where(x => navigator.Content is ISearchablePageViewModel) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => ((ISearchablePageViewModel)navigator.Content).SearchQuery = x); + } + + /// + public IConnection Connection + { + get; + private set; + } + + /// + public IViewModel Content + { + get { return content; } + private set { this.RaiseAndSetIfChanged(ref content, value); } + } + + /// + public ContentOverride ContentOverride => contentOverride.Value; + + /// + public bool IsSearchEnabled => isSearchEnabled.Value; + + /// + public ILocalRepositoryModel LocalRepository + { + get { return localRepository; } + private set { this.RaiseAndSetIfChanged(ref localRepository, value); } + } + + /// + public string SearchQuery + { + get { return searchQuery; } + set { this.RaiseAndSetIfChanged(ref searchQuery, value); } + } + + /// + public string Title => title.Value; + + /// + public void Dispose() + { + navigating.Dispose(); + } + + /// + public async Task InitializeAsync(IServiceProvider paneServiceProvider) + { + await UpdateContent(teServiceHolder.ActiveRepo); + teServiceHolder.Subscribe(this, x => UpdateContent(x).Forget()); + connectionManager.Connections.CollectionChanged += (_, __) => UpdateContent(LocalRepository).Forget(); + + BindNavigatorCommand(paneServiceProvider, PkgCmdIDList.pullRequestCommand, showPullRequests); + BindNavigatorCommand(paneServiceProvider, PkgCmdIDList.backCommand, navigator.NavigateBack); + BindNavigatorCommand(paneServiceProvider, PkgCmdIDList.forwardCommand, navigator.NavigateForward); + BindNavigatorCommand(paneServiceProvider, PkgCmdIDList.refreshCommand, refresh); + BindNavigatorCommand(paneServiceProvider, PkgCmdIDList.githubCommand, openInBrowser); + + paneServiceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.helpCommand, + (_, __) => + { + browser.OpenUrl(new Uri(GitHubUrls.Documentation)); + usageTracker.IncrementCounter(x => x.NumberOfGitHubPaneHelpClicks).Forget(); + }); + } + + /// + public async Task NavigateTo(Uri uri) + { + Guard.ArgumentNotNull(uri, nameof(uri)); + + if (uri.Scheme != "github") + { + throw new NotSupportedException("Invalid URI scheme for GitHub pane: " + uri.Scheme); + } + + if (uri.Authority != "pane") + { + throw new NotSupportedException("Invalid URI authority for GitHub pane: " + uri.Authority); + } + + Match match; + + if (uri.AbsolutePath == "/pulls") + { + await ShowPullRequests(); + } + else if (uri.AbsolutePath == "/pull/new") + { + await ShowCreatePullRequest(); + } + else if ((match = pullUri.Match(uri.AbsolutePath))?.Success == true) + { + var owner = match.Groups["owner"].Value; + var repo = match.Groups["repo"].Value; + var number = int.Parse(match.Groups["number"].Value); + await ShowPullRequest(owner, repo, number); + } + else + { + throw new NotSupportedException("Unrecognised GitHub pane URL: " + uri.AbsolutePath); + } + + var queries = HttpUtility.ParseQueryString(uri.Query); + + if (queries["refresh"] == "true") + { + await navigator.Content.Refresh(); + } + } + + /// + public Task ShowDefaultPage() => ShowPullRequests(); + + /// + public Task ShowCreatePullRequest() + { + return NavigateTo(x => x.InitializeAsync(LocalRepository, Connection)); + } + + /// + public Task ShowPullRequests() + { + return NavigateTo(x => x.InitializeAsync(LocalRepository, Connection)); + } + + /// + public Task ShowPullRequest(string owner, string repo, int number) + { + Guard.ArgumentNotNull(owner, nameof(owner)); + Guard.ArgumentNotNull(repo, nameof(repo)); + + return NavigateTo( + x => x.InitializeAsync(LocalRepository, Connection, owner, repo, number), + x => x.RemoteRepositoryOwner == owner && x.LocalRepository.Name == repo && x.Number == number); + } + + OleMenuCommand BindNavigatorCommand(IServiceProvider paneServiceProvider, int commandId, ReactiveCommand command) + { + Guard.ArgumentNotNull(paneServiceProvider, nameof(paneServiceProvider)); + Guard.ArgumentNotNull(command, nameof(command)); + + Func canExecute = () => Content == navigator && command.CanExecute(null); + + var result = paneServiceProvider.AddCommandHandler( + Guids.guidGitHubToolbarCmdSet, + commandId, + canExecute, + () => command.Execute(null), + true); + + Observable.CombineLatest( + this.WhenAnyValue(x => x.Content), + command.CanExecuteObservable, + (c, e) => c == navigator && e) + .Subscribe(x => result.Enabled = x); + + return result; + } + + async Task NavigateTo(Func initialize, Func match = null) + where TViewModel : class, IPanePageViewModel + { + Guard.ArgumentNotNull(initialize, nameof(initialize)); + + if (Content != navigator) return; + await navigating.WaitAsync(); + + try + { + var viewModel = navigator.History + .OfType() + .FirstOrDefault(x => match?.Invoke(x) ?? true); + + if (viewModel == null) + { + viewModel = viewModelFactory.CreateViewModel(); + navigator.NavigateTo(viewModel); + await initialize(viewModel); + } + else if (navigator.Content != viewModel) + { + navigator.NavigateTo(viewModel); + } + } + finally + { + navigating.Release(); + } + } + + async Task UpdateContent(ILocalRepositoryModel repository) + { + LocalRepository = repository; + Connection = null; + + Content = null; + + if (repository == null) + { + Content = notAGitRepository; + return; + } + else if (string.IsNullOrWhiteSpace(repository.CloneUrl)) + { + Content = notAGitHubRepository; + return; + } + + var repositoryUrl = repository.CloneUrl.ToRepositoryUrl(); + var isDotCom = HostAddress.IsGitHubDotComUri(repositoryUrl); + var client = await apiClientFactory.Create(repository.CloneUrl); + var isEnterprise = isDotCom ? false : client.IsEnterprise(); + + if ((isDotCom || isEnterprise) && await IsValidRepository(client)) + { + var hostAddress = HostAddress.Create(repository.CloneUrl); + + Connection = await connectionManager.GetConnection(hostAddress); + + if (Connection != null) + { + navigator.Clear(); + Content = navigator; + await ShowDefaultPage(); + } + else + { + Content = loggedOut; + } + } + else + { + Content = notAGitHubRepository; + } + } + + static async Task IsValidRepository(ISimpleApiClient client) + { + try + { + var repo = await client.GetRepository(); + return repo.Id != 0; + } + catch + { + return false; + } + } + + static Regex CreateRoute(string route) + { + // Build RegEx from route (:foo to named group (?[a-z0-9]+)). + var routeFormat = new Regex("(:([a-z]+))\\b").Replace(route, "(?<$2>[a-z0-9]+)"); + return new Regex(routeFormat, RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase); + } + } +} diff --git a/src/GitHub.App/ViewModels/LoggedOutViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/LoggedOutViewModel.cs similarity index 74% rename from src/GitHub.App/ViewModels/LoggedOutViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/LoggedOutViewModel.cs index 26371d3766..42f8b9f369 100644 --- a/src/GitHub.App/ViewModels/LoggedOutViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/LoggedOutViewModel.cs @@ -1,31 +1,29 @@ using System; using System.ComponentModel.Composition; using System.Reactive.Linq; -using GitHub.Exports; using GitHub.Info; using GitHub.Services; -using GitHub.UI; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// The view model for the "Sign in to GitHub" view in the GitHub pane. /// - [ExportViewModel(ViewType = UIViewType.LoggedOut)] + [Export(typeof(ILoggedOutViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public class LoggedOutViewModel : PanePageViewModelBase, ILoggedOutViewModel { - readonly IUIProvider uiProvider; + readonly IDialogService dialogService; readonly IVisualStudioBrowser browser; /// /// Initializes a new instance of the class. /// [ImportingConstructor] - public LoggedOutViewModel(IUIProvider uiProvider, IVisualStudioBrowser browser) + public LoggedOutViewModel(IDialogService dialogService, IVisualStudioBrowser browser) { - this.uiProvider = uiProvider; + this.dialogService = dialogService; this.browser = browser; SignIn = ReactiveCommand.Create(); SignIn.Subscribe(_ => OnSignIn()); @@ -45,9 +43,9 @@ public LoggedOutViewModel(IUIProvider uiProvider, IVisualStudioBrowser browser) void OnSignIn() { // Show the Sign In dialog. We don't need to listen to the outcome of this: the parent - // GitHubPaneViewModel will listen to RepositoryHosts.IsLoggedInToAnyHost and close - // this view when the user logs in. - uiProvider.RunInDialog(UIControllerFlow.Authentication); + // GitHubPaneViewModel will listen to the ConnectionManager and close this view when + // the user logs in. + dialogService.ShowLoginDialog(); } /// diff --git a/src/GitHub.App/ViewModels/GitHubPane/NavigationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NavigationViewModel.cs new file mode 100644 index 0000000000..c50f98c77c --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/NavigationViewModel.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel.Composition; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using GitHub.Extensions; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model that supports back/forward navigation of an inner content page. + /// + [Export(typeof(INavigationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class NavigationViewModel : ViewModelBase, INavigationViewModel + { + readonly ReactiveList history; + readonly ObservableAsPropertyHelper content; + Dictionary pageDispose; + int index = -1; + + /// + /// Initializes a new instance of the class. + /// + public NavigationViewModel() + { + history = new ReactiveList(); + history.BeforeItemsAdded.Subscribe(BeforeItemAdded); + history.CollectionChanged += CollectionChanged; + + var pos = this.WhenAnyValue( + x => x.Index, + x => x.History.Count, + (i, c) => new { Index = i, Count = c }); + + content = pos + .Where(x => x.Index < x.Count) + .Select(x => x.Index != -1 ? history[x.Index] : null) + .StartWith((IPanePageViewModel)null) + .ToProperty(this, x => x.Content); + + NavigateBack = ReactiveCommand.Create(pos.Select(x => x.Index > 0)); + NavigateBack.Subscribe(_ => Back()); + NavigateForward = ReactiveCommand.Create(pos.Select(x => x.Index < x.Count - 1)); + NavigateForward.Subscribe(_ => Forward()); + } + + /// + public IPanePageViewModel Content => content.Value; + + /// + public int Index + { + get { return index; } + set { this.RaiseAndSetIfChanged(ref index, value); } + } + + /// + public IReadOnlyReactiveList History => history; + + /// + public ReactiveCommand NavigateBack { get; } + + /// + public ReactiveCommand NavigateForward { get; } + + /// + public void NavigateTo(IPanePageViewModel page) + { + Guard.ArgumentNotNull(page, nameof(page)); + + if (index < history.Count - 1) + { + history.RemoveRange(index + 1, history.Count - (index + 1)); + } + + history.Add(page); + ++Index; + } + + /// + public bool Back() + { + if (index == 0) + return false; + --Index; + return true; + } + + /// + public bool Forward() + { + if (index >= history.Count - 1) + return false; + ++Index; + return true; + } + + /// + public void Clear() + { + Index = -1; + history.Clear(); + } + + public int RemoveAll(IPanePageViewModel page) + { + var count = 0; + while (history.Remove(page)) ++count; + return count; + } + + public void RegisterDispose(IPanePageViewModel page, IDisposable dispose) + { + if (pageDispose == null) + { + pageDispose = new Dictionary(); + } + + CompositeDisposable item; + + if (!pageDispose.TryGetValue(page, out item)) + { + item = new CompositeDisposable(); + pageDispose.Add(page, item); + } + + item.Add(dispose); + } + + void BeforeItemAdded(IPanePageViewModel page) + { + if (page.CloseRequested != null && !history.Contains(page)) + { + RegisterDispose( + page, + page.CloseRequested.Subscribe(_ => RemoveAll(page))); + } + } + + void ItemRemoved(int removedIndex, IPanePageViewModel page) + { + if (removedIndex <= Index) + { + --Index; + } + + if (!history.Contains(page)) + { + CompositeDisposable dispose = null; + + if (pageDispose?.TryGetValue(page, out dispose) == true) + { + dispose.Dispose(); + pageDispose.Remove(page); + } + } + } + + void CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + using (DelayChangeNotifications()) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + break; + + case NotifyCollectionChangedAction.Remove: + for (var i = 0; i < e.OldItems.Count; ++i) + { + ItemRemoved(e.OldStartingIndex + i, (IPanePageViewModel)e.OldItems[i]); + } + break; + + case NotifyCollectionChangedAction.Reset: + if (pageDispose != null) + { + foreach (var dispose in pageDispose.Values) + { + dispose.Dispose(); + } + + pageDispose.Clear(); + } + break; + + default: + throw new NotImplementedException(); + } + } + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/NotAGitHubRepositoryViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NotAGitHubRepositoryViewModel.cs similarity index 91% rename from src/GitHub.App/ViewModels/NotAGitHubRepositoryViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/NotAGitHubRepositoryViewModel.cs index 847f24ff8d..bb349b7078 100644 --- a/src/GitHub.App/ViewModels/NotAGitHubRepositoryViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/NotAGitHubRepositoryViewModel.cs @@ -1,15 +1,14 @@ using System; using System.ComponentModel.Composition; -using GitHub.Exports; using GitHub.Services; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// The view model for the "Not a GitHub repository" view in the GitHub pane. /// - [ExportViewModel(ViewType = UIViewType.NotAGitHubRepository)] + [Export(typeof(INotAGitHubRepositoryViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public class NotAGitHubRepositoryViewModel : PanePageViewModelBase, INotAGitHubRepositoryViewModel { diff --git a/src/GitHub.App/ViewModels/NotAGitRepositoryViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/NotAGitRepositoryViewModel.cs similarity index 73% rename from src/GitHub.App/ViewModels/NotAGitRepositoryViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/NotAGitRepositoryViewModel.cs index 6c20e73955..ee9e046f85 100644 --- a/src/GitHub.App/ViewModels/NotAGitRepositoryViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/NotAGitRepositoryViewModel.cs @@ -1,12 +1,11 @@ using System.ComponentModel.Composition; -using GitHub.Exports; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// The view model for the "Not a Git repository" view in the GitHub pane. /// - [ExportViewModel(ViewType = UIViewType.NotAGitRepository)] + [Export(typeof(INotAGitRepositoryViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public class NotAGitRepositoryViewModel : PanePageViewModelBase, INotAGitRepositoryViewModel { diff --git a/src/GitHub.App/ViewModels/GitHubPane/PanePageViewModelBase.cs b/src/GitHub.App/ViewModels/GitHubPane/PanePageViewModelBase.cs new file mode 100644 index 0000000000..6b14a8648d --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PanePageViewModelBase.cs @@ -0,0 +1,94 @@ +using System; +using System.Reactive; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Base class for view models that appear as a page in a the GitHub pane. + /// + public abstract class PanePageViewModelBase : ViewModelBase, IPanePageViewModel, IDisposable + { + static readonly Uri paneUri = new Uri("github://pane"); + Subject navigate = new Subject(); + Exception error; + bool isBusy; + bool isLoading; + string title; + + /// + /// Initializes a new instance of the class. + /// + protected PanePageViewModelBase() + { + } + + ~PanePageViewModelBase() + { + Dispose(false); + } + + /// + public Exception Error + { + get { return error; } + protected set { this.RaiseAndSetIfChanged(ref error, value); } + } + + /// + public bool IsBusy + { + get { return isBusy; } + protected set { this.RaiseAndSetIfChanged(ref isBusy, value); } + } + + /// + public bool IsLoading + { + get { return isLoading; } + protected set { this.RaiseAndSetIfChanged(ref isLoading, value); } + } + + /// + public string Title + { + get { return title; } + protected set { this.RaiseAndSetIfChanged(ref title, value); } + } + + /// + public virtual IObservable CloseRequested { get; } + + /// + public IObservable NavigationRequested => navigate; + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public virtual Task Refresh() => Task.CompletedTask; + + /// + /// Sends a request to navigate to a new page. + /// + /// + /// The path portion of the URI of the new page, e.g. "pulls". + /// + protected void NavigateTo(string uri) => navigate.OnNext(new Uri(paneUri, new Uri(uri, UriKind.Relative))); + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + navigate?.Dispose(); + navigate = null; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs similarity index 81% rename from src/GitHub.App/ViewModels/PullRequestCreationViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index cd41ca505f..2e03d56545 100644 --- a/src/GitHub.App/ViewModels/PullRequestCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs @@ -7,78 +7,49 @@ using System.Reactive; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Threading.Tasks; using GitHub.App; -using GitHub.Exports; using GitHub.Extensions; using GitHub.Extensions.Reactive; using GitHub.Factories; using GitHub.Logging; using GitHub.Models; using GitHub.Services; -using GitHub.UI; using GitHub.Validation; using Octokit; using ReactiveUI; using Serilog; using IConnection = GitHub.Models.IConnection; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { - [ExportViewModel(ViewType = UIViewType.PRCreation)] + [Export(typeof(IPullRequestCreationViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")] - public class PullRequestCreationViewModel : DialogViewModelBase, IPullRequestCreationViewModel, IDisposable + public class PullRequestCreationViewModel : PanePageViewModelBase, IPullRequestCreationViewModel { static readonly ILogger log = LogManager.ForContext(); - readonly ObservableAsPropertyHelper githubRepository; readonly ObservableAsPropertyHelper isExecuting; - readonly IModelService modelService; - readonly IObservable githubObs; + readonly IPullRequestService service; + readonly IModelServiceFactory modelServiceFactory; readonly CompositeDisposable disposables = new CompositeDisposable(); - readonly ILocalRepositoryModel activeLocalRepo; + ILocalRepositoryModel activeLocalRepo; + ObservableAsPropertyHelper githubRepository; + IModelService modelService; [ImportingConstructor] - PullRequestCreationViewModel( - IGlobalConnection connection, - IModelServiceFactory modelServiceFactory, - ITeamExplorerServiceHolder teservice, - IPullRequestService service, INotificationService notifications) - : this(connection.Get(), modelServiceFactory, teservice?.ActiveRepo, service, notifications) - {} - public PullRequestCreationViewModel( - IConnection connection, IModelServiceFactory modelServiceFactory, - ILocalRepositoryModel activeRepo, IPullRequestService service, INotificationService notifications) { - Guard.ArgumentNotNull(connection, nameof(connection)); Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); - Guard.ArgumentNotNull(activeRepo, nameof(activeRepo)); Guard.ArgumentNotNull(service, nameof(service)); Guard.ArgumentNotNull(notifications, nameof(notifications)); - activeLocalRepo = activeRepo; - modelService = modelServiceFactory.CreateBlocking(connection); - - var obs = modelService.ApiClient.GetRepository(activeRepo.Owner, activeRepo.Name) - .Select(r => new RemoteRepositoryModel(r)) - .PublishLast(); - disposables.Add(obs.Connect()); - githubObs = obs; - - githubRepository = githubObs.ToProperty(this, x => x.GitHubRepository); - - this.WhenAnyValue(x => x.GitHubRepository) - .WhereNotNull() - .Subscribe(r => - { - TargetBranch = r.IsFork ? r.Parent.DefaultBranch : r.DefaultBranch; - }); - - SourceBranch = activeRepo.CurrentBranch; + this.service = service; + this.modelServiceFactory = modelServiceFactory; this.WhenAnyValue(x => x.Branches) .WhereNotNull() @@ -91,31 +62,6 @@ public PullRequestCreationViewModel( SetupValidators(); - var uniqueCommits = this.WhenAnyValue( - x => x.SourceBranch, - x => x.TargetBranch) - .Where(x => x.Item1 != null && x.Item2 != null) - .Select(branches => - { - var baseBranch = branches.Item1.Name; - var compareBranch = branches.Item2.Name; - - // We only need to get max two commits for what we're trying to achieve here. - // If there's no commits we want to block creation of the PR, if there's one commits - // we wan't to use its commit message as the PR title/body and finally if there's more - // than one we'll use the branch name for the title. - return service.GetMessagesForUniqueCommits(activeRepo, baseBranch, compareBranch, maxCommits: 2) - .Catch, Exception>(ex => - { - log.Warning(ex, "Could not load unique commits"); - return Observable.Empty>(); - }); - }) - .Switch() - .ObserveOn(RxApp.MainThreadScheduler) - .Replay(1) - .RefCount(); - var whenAnyValidationResultChanges = this.WhenAny( x => x.TitleValidator.ValidationResult, x => x.BranchValidator.ValidationResult, @@ -129,7 +75,7 @@ public PullRequestCreationViewModel( CreatePullRequest = ReactiveCommand.CreateAsyncObservable(whenAnyValidationResultChanges, _ => service - .CreatePullRequest(modelService, activeRepo, TargetBranch.Repository, SourceBranch, TargetBranch, PRTitle, Description ?? String.Empty) + .CreatePullRequest(modelService, activeLocalRepo, TargetBranch.Repository, SourceBranch, TargetBranch, PRTitle, Description ?? String.Empty) .Catch(ex => { log.Error(ex, "Error creating pull request"); @@ -144,18 +90,91 @@ public PullRequestCreationViewModel( { notifications.ShowMessage(String.Format(CultureInfo.CurrentCulture, Resources.PRCreatedUpstream, SourceBranch.DisplayName, TargetBranch.Repository.Owner + "/" + TargetBranch.Repository.Name + "#" + pr.Number, TargetBranch.Repository.CloneUrl.ToRepositoryUrl().Append("pull/" + pr.Number))); + NavigateTo("/pulls?refresh=true"); + Cancel.Execute(null); }); + Cancel = ReactiveCommand.Create(); + isExecuting = CreatePullRequest.IsExecuting.ToProperty(this, x => x.IsExecuting); - this.WhenAnyValue(x => x.Initialized, x => x.GitHubRepository, x => x.Description, x => x.IsExecuting) - .Select(x => !(x.Item1 && x.Item2 != null && x.Item3 != null && !x.Item4)) + this.WhenAnyValue(x => x.Initialized, x => x.GitHubRepository, x => x.IsExecuting) + .Select(x => !(x.Item1 && x.Item2 != null && !x.Item3)) .Subscribe(x => IsBusy = x); + } + + public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) + { + modelService = await modelServiceFactory.CreateAsync(connection); + activeLocalRepo = repository; + SourceBranch = repository.CurrentBranch; + + var obs = modelService.ApiClient.GetRepository(repository.Owner, repository.Name) + .Select(r => new RemoteRepositoryModel(r)) + .PublishLast(); + disposables.Add(obs.Connect()); + var githubObs = obs; + + githubRepository = githubObs.ToProperty(this, x => x.GitHubRepository); + + this.WhenAnyValue(x => x.GitHubRepository) + .WhereNotNull() + .Subscribe(r => + { + TargetBranch = r.IsFork ? r.Parent.DefaultBranch : r.DefaultBranch; + }); + + githubObs.SelectMany(r => + { + var b = Observable.Empty(); + if (r.IsFork) + { + b = modelService.GetBranches(r.Parent).Select(x => + { + return x; + }); + } + return b.Concat(modelService.GetBranches(r)); + }) + .ToList() + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(x => + { + Branches = x.ToList(); + Initialized = true; + }); + + SourceBranch = activeLocalRepo.CurrentBranch; + + var uniqueCommits = this.WhenAnyValue( + x => x.SourceBranch, + x => x.TargetBranch) + .Where(x => x.Item1 != null && x.Item2 != null) + .Select(branches => + { + var baseBranch = branches.Item1.Name; + var compareBranch = branches.Item2.Name; + + // We only need to get max two commits for what we're trying to achieve here. + // If there's no commits we want to block creation of the PR, if there's one commits + // we wan't to use its commit message as the PR title/body and finally if there's more + // than one we'll use the branch name for the title. + return service.GetMessagesForUniqueCommits(activeLocalRepo, baseBranch, compareBranch, maxCommits: 2) + .Catch, Exception>(ex => + { + log.Warning(ex, "Could not load unique commits"); + return Observable.Empty>(); + }); + }) + .Switch() + .ObserveOn(RxApp.MainThreadScheduler) + .Replay(1) + .RefCount(); Observable.CombineLatest( this.WhenAnyValue(x => x.SourceBranch), uniqueCommits, - service.GetPullRequestTemplate(activeRepo).DefaultIfEmpty(string.Empty), + service.GetPullRequestTemplate(repository).DefaultIfEmpty(string.Empty), (compare, commits, template) => new { compare, commits, template }) .Subscribe(x => { @@ -182,35 +201,8 @@ public PullRequestCreationViewModel( PRTitle = prTitle; Description = prDescription; }); - } - - public override void Initialize(ViewWithData data = null) - { - base.Initialize(data); - - Initialized = false; - - githubObs.SelectMany(r => - { - var b = Observable.Empty(); - if (r.IsFork) - { - b = modelService.GetBranches(r.Parent).Select(x => - { - return x; - }); - } - return b.Concat(modelService.GetBranches(r)); - }) - .ToList() - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(x => - { - Branches = x.ToList(); - Initialized = true; - }); - SourceBranch = activeLocalRepo.CurrentBranch; + Initialized = true; } void SetupValidators() @@ -232,8 +224,10 @@ void SetupValidators() } bool disposed; // To detect redundant calls - void Dispose(bool disposing) + protected override void Dispose(bool disposing) { + base.Dispose(disposing); + if (disposing) { if (disposed) return; @@ -243,12 +237,6 @@ void Dispose(bool disposing) } } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - public IRemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } bool IsExecuting { get { return isExecuting.Value; } } @@ -281,6 +269,7 @@ public IReadOnlyList Branches } public IReactiveCommand CreatePullRequest { get; } + public IReactiveCommand Cancel { get; } string title; public string PRTitle @@ -310,6 +299,6 @@ ReactivePropertyValidator BranchValidator set { this.RaiseAndSetIfChanged(ref branchValidator, value); } } - public override IObservable Done => CreatePullRequest.SelectUnit(); + public override IObservable CloseRequested => Cancel.SelectUnit(); } } diff --git a/src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs similarity index 83% rename from src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs index 21e46743e1..a6a7df7225 100644 --- a/src/GitHub.App/ViewModels/PullRequestDetailViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs @@ -8,33 +8,33 @@ using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using GitHub.App; -using GitHub.Exports; using GitHub.Extensions; using GitHub.Factories; +using GitHub.Helpers; using GitHub.Logging; using GitHub.Models; using GitHub.Services; -using GitHub.UI; using LibGit2Sharp; using ReactiveUI; using Serilog; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// A view model which displays the details of a pull request. /// - [ExportViewModel(ViewType = UIViewType.PRDetail)] + [Export(typeof(IPullRequestDetailViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullRequestDetailViewModel, IDisposable + public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullRequestDetailViewModel { static readonly ILogger log = LogManager.ForContext(); - readonly IModelService modelService; + readonly IModelServiceFactory modelServiceFactory; readonly IPullRequestService pullRequestsService; readonly IPullRequestSessionManager sessionManager; readonly IUsageTracker usageTracker; readonly IVSGitExt vsGitExt; + IModelService modelService; IPullRequestModel model; string sourceBranchDisplayName; string targetBranchDisplayName; @@ -43,67 +43,39 @@ public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullReq IReadOnlyList changedFilesTree; IPullRequestCheckoutState checkoutState; IPullRequestUpdateState updateState; - string errorMessage; string operationError; - bool isBusy; - bool isLoading; bool isCheckedOut; bool isFromFork; bool isInCheckout; + Uri webUrl; /// /// Initializes a new instance of the class. /// - /// The connection repository host map. - /// The team explorer service. + /// The local repository. + /// The model service. /// The pull requests service. /// The pull request session manager. /// The usage tracker. + /// The Visual Studio git service. [ImportingConstructor] - PullRequestDetailViewModel( - IGlobalConnection connection, - IModelServiceFactory modelServiceFactory, - ITeamExplorerServiceHolder teservice, + public PullRequestDetailViewModel( IPullRequestService pullRequestsService, IPullRequestSessionManager sessionManager, + IModelServiceFactory modelServiceFactory, IUsageTracker usageTracker, IVSGitExt vsGitExt) - : this(teservice.ActiveRepo, - modelServiceFactory.CreateBlocking(connection.Get()), - pullRequestsService, - sessionManager, - usageTracker) { - this.vsGitExt = vsGitExt; - - vsGitExt.ActiveRepositoriesChanged += Refresh; - } - - public void Dispose() - { - vsGitExt.ActiveRepositoriesChanged -= Refresh; - } + Guard.ArgumentNotNull(pullRequestsService, nameof(pullRequestsService)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); - /// - /// Initializes a new instance of the class. - /// - /// The local repository. - /// The model service. - /// The pull requests service. - /// The pull request session manager. - /// The usage tracker. - public PullRequestDetailViewModel( - ILocalRepositoryModel localRepository, - IModelService modelService, - IPullRequestService pullRequestsService, - IPullRequestSessionManager sessionManager, - IUsageTracker usageTracker) - { - this.LocalRepository = localRepository; - this.modelService = modelService; this.pullRequestsService = pullRequestsService; this.sessionManager = sessionManager; + this.modelServiceFactory = modelServiceFactory; this.usageTracker = usageTracker; + this.vsGitExt = vsGitExt; Checkout = ReactiveCommand.CreateAsyncObservable( this.WhenAnyValue(x => x.CheckoutState) @@ -131,7 +103,7 @@ public PullRequestDetailViewModel( DiffFile = ReactiveCommand.Create(); DiffFileWithWorkingDirectory = ReactiveCommand.Create(this.WhenAnyValue(x => x.IsCheckedOut)); OpenFileInWorkingDirectory = ReactiveCommand.Create(this.WhenAnyValue(x => x.IsCheckedOut)); - ViewFile = ReactiveCommand.Create(); + ViewFile = ReactiveCommand.Create(); } /// @@ -157,7 +129,7 @@ private set /// /// Gets the local repository. /// - public ILocalRepositoryModel LocalRepository { get; } + public ILocalRepositoryModel LocalRepository { get; private set; } /// /// Gets the owner of the remote repository that contains the pull request. @@ -168,6 +140,11 @@ private set /// public string RemoteRepositoryOwner { get; private set; } + /// + /// Gets the Pull Request number. + /// + public int Number { get; private set; } + /// /// Gets the session for the pull request. /// @@ -200,15 +177,6 @@ public int CommentCount private set { this.RaiseAndSetIfChanged(ref commentCount, value); } } - /// - /// Gets a value indicating whether the view model is updating. - /// - public bool IsBusy - { - get { return isBusy; } - private set { this.RaiseAndSetIfChanged(ref isBusy, value); } - } - /// Gets a value indicating whether the pull request branch is checked out. /// public bool IsCheckedOut @@ -217,15 +185,6 @@ public bool IsCheckedOut private set { this.RaiseAndSetIfChanged(ref isCheckedOut, value); } } - /// - /// Gets a value indicating whether the view model is loading. - /// - public bool IsLoading - { - get { return isLoading; } - private set { this.RaiseAndSetIfChanged(ref isLoading, value); } - } - /// /// Gets a value indicating whether the pull request comes from a fork. /// @@ -262,15 +221,6 @@ public IPullRequestUpdateState UpdateState private set { this.RaiseAndSetIfChanged(ref updateState, value); } } - /// - /// Gets an error message to display if loading fails. - /// - public string ErrorMessage - { - get { return errorMessage; } - private set { this.RaiseAndSetIfChanged(ref errorMessage, value); } - } - /// /// Gets the error message to be displayed in the action area as a result of an error in a /// git operation. @@ -290,6 +240,15 @@ public IReadOnlyList ChangedFilesTree private set { this.RaiseAndSetIfChanged(ref changedFilesTree, value); } } + /// + /// Gets the web URL for the pull request. + /// + public Uri WebUrl + { + get { return webUrl; } + private set { this.RaiseAndSetIfChanged(ref webUrl, value); } + } + /// /// Gets a command that checks out the pull request locally. /// @@ -332,75 +291,65 @@ public IReadOnlyList ChangedFilesTree public ReactiveCommand ViewFile { get; } /// - /// Initializes the view model with new data. + /// Initializes the view model. /// - /// - public override void Initialize(ViewWithData data) + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request number. + public async Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int number) { - int number; - var repoOwner = RemoteRepositoryOwner; - - if (data != null) - { - var arg = (PullRequestDetailArgument)data.Data; - number = arg.Number; - repoOwner = arg.RepositoryOwner; - } - else + if (repo != localRepository.Name) { - number = Model.Number; + throw new NotSupportedException(); } - if (Model == null) + IsLoading = true; + + try { - IsLoading = true; + LocalRepository = localRepository; + RemoteRepositoryOwner = owner; + Number = number; + WebUrl = LocalRepository.CloneUrl.ToRepositoryUrl().Append("pull/" + number); + modelService = await modelServiceFactory.CreateAsync(connection); + vsGitExt.ActiveRepositoriesChanged += ActiveRepositoriesChanged; + await Refresh(); } - else + finally { - IsBusy = true; + IsLoading = false; } - - ErrorMessage = OperationError = null; - modelService.GetPullRequest(repoOwner, LocalRepository.Name, number) - .TakeLast(1) - .ObserveOn(RxApp.MainThreadScheduler) - .Catch(ex => - { - log.Error(ex, "Error observing GetPullRequest"); - ErrorMessage = ex.Message.Trim(); - IsLoading = IsBusy = false; - return Observable.Empty(); - }) - .Subscribe(x => Load(repoOwner, x).Forget()); } /// /// Loads the view model from octokit models. /// - /// The owner of the remote repository. /// The pull request model. - public async Task Load(string remoteRepositoryOwner, IPullRequestModel pullRequest) + public async Task Load(IPullRequestModel pullRequest) { - Guard.ArgumentNotNull(remoteRepositoryOwner, nameof(remoteRepositoryOwner)); - try { var firstLoad = (Model == null); - Model = pullRequest; - RemoteRepositoryOwner = remoteRepositoryOwner; + Model = pullRequest; Session = await sessionManager.GetSession(pullRequest); Title = Resources.PullRequestNavigationItemText + " #" + pullRequest.Number; + IsBusy = true; IsFromFork = !pullRequestsService.IsPullRequestFromRepository(LocalRepository, Model); SourceBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Head?.Label); - TargetBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Base.Label); + TargetBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Base?.Label); CommentCount = pullRequest.Comments.Count + pullRequest.ReviewComments.Count; Body = !string.IsNullOrWhiteSpace(pullRequest.Body) ? pullRequest.Body : Resources.NoDescriptionProvidedMarkdown; - using (var changes = await pullRequestsService.GetTreeChanges(LocalRepository, pullRequest)) - { - ChangedFilesTree = (await CreateChangedFilesTree(pullRequest, changes)).Children.ToList(); - } + var changes = await pullRequestsService.GetTreeChanges(LocalRepository, pullRequest); + ChangedFilesTree = (await CreateChangedFilesTree(pullRequest, changes)).Children.ToList(); var localBranches = await pullRequestsService.GetLocalBranches(LocalRepository, pullRequest).ToList(); @@ -476,14 +425,36 @@ public async Task Load(string remoteRepositoryOwner, IPullRequestModel pullReque pullRequestsService.RemoveUnusedRemotes(LocalRepository).Subscribe(_ => { }); } } - catch (Exception ex) + finally { - log.Error(ex, "Error loading PullRequestModel"); - ErrorMessage = ex.Message.Trim(); + IsBusy = false; } - finally + } + + /// + /// Refreshes the contents of the view model. + /// + public override async Task Refresh() + { + try { - IsLoading = IsBusy = false; + Error = null; + OperationError = null; + IsBusy = true; + var pullRequest = await modelService.GetPullRequest(RemoteRepositoryOwner, LocalRepository.Name, Number); + await Load(pullRequest); + } + catch (Exception ex) + { + log.Error( + ex, + "Error loading pull request {Owner}/{Repo}/{Number} from {Address}", + RemoteRepositoryOwner, + LocalRepository.Name, + Number, + modelService.ApiClient.HostAddress.Title); + Error = ex; + IsBusy = false; } } @@ -512,15 +483,27 @@ public string GetLocalFilePath(IPullRequestFileNode file) return Path.Combine(LocalRepository.LocalPath, file.DirectoryPath, file.FileName); } - async void Refresh() + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + vsGitExt.ActiveRepositoriesChanged -= ActiveRepositoriesChanged; + } + } + + async void ActiveRepositoriesChanged() { try { - await Load(RemoteRepositoryOwner, Model); + await ThreadingHelper.SwitchToMainThreadAsync(); + await Refresh(); } - catch (Exception e) + catch (Exception ex) { - log.Error(e, "Error refreshing model"); + log.Error(ex, "Error refreshing in ActiveRepositoriesChanged."); } } diff --git a/src/GitHub.App/ViewModels/PullRequestDirectoryNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs similarity index 97% rename from src/GitHub.App/ViewModels/PullRequestDirectoryNode.cs rename to src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs index c249569d2e..26b12b1dad 100644 --- a/src/GitHub.App/ViewModels/PullRequestDirectoryNode.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// A directory node in a pull request changes tree. diff --git a/src/GitHub.App/ViewModels/PullRequestFileNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs similarity index 98% rename from src/GitHub.App/ViewModels/PullRequestFileNode.cs rename to src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs index a55dd08456..2b4e318030 100644 --- a/src/GitHub.App/ViewModels/PullRequestFileNode.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs @@ -4,7 +4,7 @@ using GitHub.Models; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// A file node in a pull request changes tree. diff --git a/src/GitHub.App/ViewModels/PullRequestListViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs similarity index 80% rename from src/GitHub.App/ViewModels/PullRequestListViewModel.cs rename to src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs index df8d50f828..1c26794697 100644 --- a/src/GitHub.App/ViewModels/PullRequestListViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs @@ -2,84 +2,58 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel.Composition; -using System.Globalization; using System.Linq; using System.Reactive.Linq; -using System.Reactive.Subjects; using System.Threading.Tasks; using System.Windows.Media.Imaging; using GitHub.App; using GitHub.Collections; -using GitHub.Exports; using GitHub.Extensions; using GitHub.Factories; using GitHub.Logging; using GitHub.Models; using GitHub.Services; using GitHub.Settings; -using GitHub.UI; using ReactiveUI; using Serilog; +using static System.FormattableString; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { - [ExportViewModel(ViewType = UIViewType.PRList)] + [Export(typeof(IPullRequestListViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class PullRequestListViewModel : PanePageViewModelBase, IPullRequestListViewModel, IDisposable + public class PullRequestListViewModel : PanePageViewModelBase, IPullRequestListViewModel { static readonly ILogger log = LogManager.ForContext(); - readonly IConnection connection; readonly IModelServiceFactory modelServiceFactory; - readonly ILocalRepositoryModel localRepository; readonly TrackingCollection trackingAuthors; readonly TrackingCollection trackingAssignees; readonly IPackageSettings settings; readonly IVisualStudioBrowser visualStudioBrowser; - readonly PullRequestListUIState listSettings; readonly bool constructing; + PullRequestListUIState listSettings; + ILocalRepositoryModel localRepository; IRemoteRepositoryModel remoteRepository; IModelService modelService; [ImportingConstructor] - PullRequestListViewModel( - IGlobalConnection connection, - IModelServiceFactory modelServiceFactory, - ITeamExplorerServiceHolder teservice, - IPackageSettings settings, - IVisualStudioBrowser visualStudioBrowser) - : this(connection.Get(), modelServiceFactory, teservice.ActiveRepo, settings, visualStudioBrowser) - { - Guard.ArgumentNotNull(connection, nameof(connection)); - Guard.ArgumentNotNull(teservice, nameof(teservice)); - Guard.ArgumentNotNull(settings, nameof(settings)); - } - public PullRequestListViewModel( - IConnection connection, IModelServiceFactory modelServiceFactory, - ILocalRepositoryModel repository, IPackageSettings settings, IVisualStudioBrowser visualStudioBrowser) { - Guard.ArgumentNotNull(connection, nameof(connection)); - Guard.ArgumentNotNull(repository, nameof(repository)); + Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); Guard.ArgumentNotNull(settings, nameof(settings)); Guard.ArgumentNotNull(visualStudioBrowser, nameof(visualStudioBrowser)); constructing = true; - this.connection = connection; this.modelServiceFactory = modelServiceFactory; - this.localRepository = repository; this.settings = settings; this.visualStudioBrowser = visualStudioBrowser; Title = Resources.PullRequestsNavigationItemText; - this.listSettings = settings.UIState - .GetOrCreateRepositoryState(repository.CloneUrl) - .PullRequests; - States = new List { new PullRequestState { IsOpen = true, Name = "Open" }, new PullRequestState { IsOpen = false, Name = "Closed" }, @@ -114,7 +88,6 @@ public PullRequestListViewModel( .Where(x => PullRequests != null) .Subscribe(f => UpdateFilter(SelectedState, SelectedAssignee, SelectedAuthor, f)); - SelectedState = States.FirstOrDefault(x => x.Name == listSettings.SelectedState) ?? States[0]; OpenPullRequest = ReactiveCommand.Create(); OpenPullRequest.Subscribe(DoOpenPullRequest); CreatePullRequest = ReactiveCommand.Create(); @@ -126,31 +99,39 @@ public PullRequestListViewModel( constructing = false; } - public override void Initialize(ViewWithData data) - { - base.Initialize(data); - Load().Forget(); - } - - async Task Load() + public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) { - IsBusy = true; + IsLoading = true; - if (modelService == null) + try { modelService = await modelServiceFactory.CreateAsync(connection); - } - - if (remoteRepository == null) - { + listSettings = settings.UIState + .GetOrCreateRepositoryState(repository.CloneUrl) + .PullRequests; + localRepository = repository; remoteRepository = await modelService.GetRepository( localRepository.Owner, localRepository.Name); Repositories = remoteRepository.IsFork ? new[] { remoteRepository.Parent, remoteRepository } : new[] { remoteRepository }; + SelectedState = States.FirstOrDefault(x => x.Name == listSettings.SelectedState) ?? States[0]; SelectedRepository = Repositories[0]; + WebUrl = repository.CloneUrl?.ToRepositoryUrl().Append("pulls"); + await Load(); + } + finally + { + IsLoading = false; } + } + + public override Task Refresh() => Load(); + + Task Load() + { + IsBusy = true; PullRequests = modelService.GetPullRequests(SelectedRepository, pullRequests); pullRequests.Subscribe(pr => @@ -161,11 +142,6 @@ async Task Load() pullRequests.OriginalCompleted .ObserveOn(RxApp.MainThreadScheduler) - ////.Catch(ex => - ////{ - //// log.Info("Received AuthorizationException reading pull requests", ex); - //// return repositoryHost.LogOut(); - ////}) .Catch(ex => { // Occurs on network error, when the repository was deleted on GitHub etc. @@ -187,6 +163,7 @@ async Task Load() IsBusy = false; UpdateFilter(SelectedState, SelectedAssignee, SelectedAuthor, SearchQuery); }); + return Task.CompletedTask; } void UpdateFilter(PullRequestState state, IAccount ass, IAccount aut, string filText) @@ -231,13 +208,6 @@ public string SearchQuery set { this.RaiseAndSetIfChanged(ref searchQuery, value); } } - bool isBusy; - public bool IsBusy - { - get { return isBusy; } - private set { this.RaiseAndSetIfChanged(ref isBusy, value); } - } - IReadOnlyList repositories; public IReadOnlyList Repositories { @@ -314,19 +284,24 @@ public IAccount EmptyUser get { return emptyUser; } } - public bool IsSearchEnabled => true; + Uri webUrl; + public Uri WebUrl + { + get { return webUrl; } + private set { this.RaiseAndSetIfChanged(ref webUrl, value); } + } - readonly Subject navigate = new Subject(); - public IObservable Navigate => navigate; + public bool IsSearchEnabled => true; public ReactiveCommand OpenPullRequest { get; } public ReactiveCommand CreatePullRequest { get; } - public ReactiveCommand OpenPullRequestOnGitHub { get; } bool disposed; - protected void Dispose(bool disposing) + protected override void Dispose(bool disposing) { + base.Dispose(disposing); + if (disposing) { if (disposed) return; @@ -337,12 +312,6 @@ protected void Dispose(bool disposing) } } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - void CreatePullRequests() { PullRequests = new TrackingCollection(); @@ -372,22 +341,13 @@ void DoOpenPullRequest(object pullRequest) { Guard.ArgumentNotNull(pullRequest, nameof(pullRequest)); - var d = new ViewWithData(UIControllerFlow.PullRequestDetail) - { - Data = new PullRequestDetailArgument - { - RepositoryOwner = SelectedRepository.Owner, - Number = (int)pullRequest, - } - }; - - navigate.OnNext(d); + var number = (int)pullRequest; + NavigateTo(Invariant($"{SelectedRepository.Owner}/{SelectedRepository.Name}/pull/{number}")); } void DoCreatePullRequest() { - var d = new ViewWithData(UIControllerFlow.PullRequestCreation); - navigate.OnNext(d); + NavigateTo("pull/new"); } void DoOpenPullRequestOnGitHub(int pullRequest) diff --git a/src/GitHub.App/ViewModels/PanePageViewModelBase.cs b/src/GitHub.App/ViewModels/PanePageViewModelBase.cs deleted file mode 100644 index 6efb85265e..0000000000 --- a/src/GitHub.App/ViewModels/PanePageViewModelBase.cs +++ /dev/null @@ -1,26 +0,0 @@ -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// Base class for view models that appear as a page in a navigable pane, such as the GitHub pane. - /// - public class PanePageViewModelBase : ViewModelBase, IPanePageViewModel - { - string title; - - /// - /// Initializes a new instance of the class. - /// - protected PanePageViewModelBase() - { - } - - /// - public string Title - { - get { return title; } - protected set { this.RaiseAndSetIfChanged(ref title, value); } - } - } -} diff --git a/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs b/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs index e4a29608cd..6aa13f02b1 100644 --- a/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs +++ b/src/GitHub.App/ViewModels/RepositoryFormViewModel.cs @@ -11,7 +11,7 @@ namespace GitHub.ViewModels /// /// Base class for the Repository publish/create dialogs. It represents the details about the repository itself. /// - public abstract class RepositoryFormViewModel : DialogViewModelBase + public abstract class RepositoryFormViewModel : ViewModelBase { readonly ObservableAsPropertyHelper safeRepositoryName; diff --git a/src/GitHub.App/ViewModels/RepositoryPublishViewModel.cs b/src/GitHub.App/ViewModels/TeamExplorer/RepositoryPublishViewModel.cs similarity index 95% rename from src/GitHub.App/ViewModels/RepositoryPublishViewModel.cs rename to src/GitHub.App/ViewModels/TeamExplorer/RepositoryPublishViewModel.cs index 31c8d8cc3f..048ccfa77a 100644 --- a/src/GitHub.App/ViewModels/RepositoryPublishViewModel.cs +++ b/src/GitHub.App/ViewModels/TeamExplorer/RepositoryPublishViewModel.cs @@ -4,10 +4,8 @@ using System.ComponentModel.Composition; using System.Globalization; using System.Linq; -using System.Reactive; using System.Reactive.Linq; using GitHub.App; -using GitHub.Exports; using GitHub.Extensions; using GitHub.Extensions.Reactive; using GitHub.Factories; @@ -19,9 +17,9 @@ using ReactiveUI; using Serilog; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.TeamExplorer { - [ExportViewModel(ViewType = UIViewType.Publish)] + [Export(typeof(IRepositoryPublishViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public class RepositoryPublishViewModel : RepositoryFormViewModel, IRepositoryPublishViewModel { @@ -116,12 +114,19 @@ public RepositoryPublishViewModel( }); } - public new string Title { get { return title.Value; } } + public string Title { get { return title.Value; } } public bool CanKeepPrivate { get { return canKeepPrivate.Value; } } public IReactiveCommand PublishRepository { get; private set; } public IReadOnlyObservableCollection Connections { get; private set; } + bool isBusy; + public bool IsBusy + { + get { return isBusy; } + private set { this.RaiseAndSetIfChanged(ref isBusy, value); } + } + IConnection selectedConnection; public IConnection SelectedConnection { @@ -139,11 +144,6 @@ public bool IsHostComboBoxVisible get { return isHostComboBoxVisible.Value; } } - public override IObservable Done - { - get { return PublishRepository.Select(x => x == ProgressState.Success).SelectUnit(); } - } - ReactiveCommand InitializePublishRepositoryCommand() { var canCreate = this.WhenAny(x => x.RepositoryNameValidator.ValidationResult.IsValid, x => x.Value); diff --git a/src/GitHub.Exports.Reactive/Collections/ISelectable.cs b/src/GitHub.Exports.Reactive/Collections/ISelectable.cs deleted file mode 100644 index f26de2b41a..0000000000 --- a/src/GitHub.Exports.Reactive/Collections/ISelectable.cs +++ /dev/null @@ -1,9 +0,0 @@ -using ReactiveUI; - -namespace GitHub.Exports -{ - public interface ISelectable : IReactiveNotifyPropertyChanged - { - bool IsSelected { get; set; } - } -} diff --git a/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs b/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs index ef13bedfff..517e34b30e 100644 --- a/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs +++ b/src/GitHub.Exports.Reactive/Extensions/ConnectionManagerExtensions.cs @@ -42,6 +42,16 @@ public static IObservable IsLoggedIn(this IConnection connection) return Observable.Return(connection?.IsLoggedIn ?? false); } + public static IObservable GetConnection(this IConnectionManager cm, HostAddress address) + { + Guard.ArgumentNotNull(cm, nameof(cm)); + Guard.ArgumentNotNull(address, nameof(address)); + + return cm.GetLoadedConnections() + .ToObservable() + .Select(x => x.FirstOrDefault(y => y.HostAddress == address)); + } + public static IObservable GetLoggedInConnections(this IConnectionManager cm) { Guard.ArgumentNotNull(cm, nameof(cm)); diff --git a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj index fd09d01f48..90809f9f7e 100644 --- a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj +++ b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj @@ -118,37 +118,41 @@ + - + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + - - - - - - - - - - - @@ -156,17 +160,16 @@ Properties\SolutionInfo.cs + + - - - diff --git a/src/GitHub.Exports.Reactive/Services/IShowDialogService.cs b/src/GitHub.Exports.Reactive/Services/IShowDialogService.cs new file mode 100644 index 0000000000..e2c3a841f6 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IShowDialogService.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; +using GitHub.Primitives; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; + +namespace GitHub.Services +{ + /// + /// Service for displaying the GitHub for Visual Studio dialog. + /// + /// + /// This is a low-level service used by to carry out the actual + /// showing of the dialog. You probably want to use instead if + /// you want to show the dialog for login/clone etc. + /// + public interface IShowDialogService + { + /// + /// Shows a view model in the dialog. + /// + /// The view model to show. + /// + /// The value returned by the 's + /// observable, or null if the dialog was + /// canceled. + /// + Task Show(IDialogContentViewModel viewModel); + + /// + /// Shows a view model that requires a connection in the dialog. + /// + /// The view model to show. + /// + /// The value returned by the 's + /// observable, or null if the dialog was + /// canceled. + /// + /// The first existing connection will be used. If there is no existing connection, the + /// login dialog will be shown first. + /// + Task ShowWithFirstConnection(TViewModel viewModel) + where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel; + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IDialogContentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IDialogContentViewModel.cs new file mode 100644 index 0000000000..4202f71f3c --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IDialogContentViewModel.cs @@ -0,0 +1,20 @@ +using System; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents a view that can be shown in the GitHub dialog. + /// + public interface IDialogContentViewModel : IViewModel + { + /// + /// Gets a title to display in the dialog title bar. + /// + string Title { get; } + + /// + /// Gets an observable that is signalled with a return value when the dialog has completed. + /// + IObservable Done { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IGistCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGistCreationViewModel.cs similarity index 86% rename from src/GitHub.Exports.Reactive/ViewModels/IGistCreationViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/IGistCreationViewModel.cs index dd3bb6a7aa..f52763ce74 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IGistCreationViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGistCreationViewModel.cs @@ -2,9 +2,9 @@ using Octokit; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - public interface IGistCreationViewModel : IDialogViewModel + public interface IGistCreationViewModel : IDialogContentViewModel, IConnectionInitializedViewModel { /// /// Gets the command to create a new gist. diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGitHubDialogWindowViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGitHubDialogWindowViewModel.cs new file mode 100644 index 0000000000..aec4977fc8 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IGitHubDialogWindowViewModel.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the top-level view model for the GitHub dialog. + /// + public interface IGitHubDialogWindowViewModel : IDisposable + { + /// + /// Gets the content to display in the dialog. + /// + IDialogContentViewModel Content { get; } + + /// + /// Gets an observable that is signalled when when the dialog should be closed. + /// + /// + /// If the content being displayed has a return value, then this wil be returned here. + /// + IObservable Done { get; } + + /// + /// Starts displaying a view model. + /// + /// The view model to display. + void Start(IDialogContentViewModel viewModel); + + /// + /// Starts displaying a view model that requires a connection. + /// + /// The view model to display. + Task StartWithConnection(T viewModel) + where T : IDialogContentViewModel, IConnectionInitializedViewModel; + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/ITwoFactorDialogViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogin2FaViewModel.cs similarity index 80% rename from src/GitHub.Exports.Reactive/ViewModels/ITwoFactorDialogViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogin2FaViewModel.cs index b713b61a81..dfc0908b52 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ITwoFactorDialogViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILogin2FaViewModel.cs @@ -3,22 +3,25 @@ using Octokit; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - public interface ITwoFactorDialogViewModel : IDialogViewModel + public interface ILogin2FaViewModel : IDialogContentViewModel { ReactiveCommand OkCommand { get; } ReactiveCommand NavigateLearnMore { get; } ReactiveCommand ResendCodeCommand { get; } IObservable Show(UserError error); + void Cancel(); + bool IsBusy { get; } bool IsSms { get; } bool IsAuthenticationCodeSent { get; } bool ShowErrorMessage { get; } bool InvalidAuthenticationCode { get; } string Description { get; } string AuthenticationCode { get; set; } + TwoFactorType TwoFactorType { get; } /// /// Gets the validator instance used for validating the diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginCredentialsViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginCredentialsViewModel.cs new file mode 100644 index 0000000000..a1673ba919 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginCredentialsViewModel.cs @@ -0,0 +1,20 @@ +using System; + +namespace GitHub.ViewModels.Dialog +{ + [Flags] + public enum LoginMode + { + None = 0x00, + DotComOnly = 0x01, + EnterpriseOnly = 0x02, + DotComOrEnterprise = 0x03, + } + + public interface ILoginCredentialsViewModel : IDialogContentViewModel + { + LoginMode LoginMode { get; } + ILoginToGitHubViewModel GitHubLogin { get; } + ILoginToGitHubForEnterpriseViewModel EnterpriseLogin { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubForEnterpriseViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubForEnterpriseViewModel.cs similarity index 96% rename from src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubForEnterpriseViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubForEnterpriseViewModel.cs index fe1352bb0a..8db2d55c79 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubForEnterpriseViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubForEnterpriseViewModel.cs @@ -2,7 +2,7 @@ using GitHub.Validation; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { /// /// Represents a view model responsible for authenticating a user diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubViewModel.cs similarity index 93% rename from src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubViewModel.cs index b56713c654..b79e2368af 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginToGitHubViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginToGitHubViewModel.cs @@ -1,7 +1,7 @@ using System.Reactive; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { /// /// Represents a view model responsible for authenticating a user diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginViewModel.cs new file mode 100644 index 0000000000..9667652cab --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/ILoginViewModel.cs @@ -0,0 +1,19 @@ +using System; + +namespace GitHub.ViewModels.Dialog +{ + /// + /// Represents the Login dialog content. + /// + public interface ILoginViewModel : IDialogContentViewModel + { + /// + /// Gets the currently displayed login content. + /// + /// + /// The value of this property will either be a + /// or a . + /// + IDialogContentViewModel Content { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs similarity index 81% rename from src/GitHub.Exports.Reactive/ViewModels/IRepositoryCloneViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs index 384e9798e1..f8742f0636 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCloneViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCloneViewModel.cs @@ -1,17 +1,14 @@ -using System.Collections.Generic; -using System.Reactive; +using System; using GitHub.Models; -using ReactiveUI; using System.Collections.ObjectModel; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { /// /// ViewModel for the the Clone Repository dialog /// - public interface IRepositoryCloneViewModel : IBaseCloneViewModel, IRepositoryCreationTarget + public interface IRepositoryCloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel { - /// /// The list of repositories the current user may clone from the specified host. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationTarget.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationTarget.cs similarity index 95% rename from src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationTarget.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationTarget.cs index 756c7e71e9..d8e2ff37c5 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationTarget.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationTarget.cs @@ -1,7 +1,7 @@ using System.Windows.Input; using GitHub.Validation; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { public interface IRepositoryCreationTarget { diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationViewModel.cs similarity index 80% rename from src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationViewModel.cs index d699663a9c..50d5c26701 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryCreationViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryCreationViewModel.cs @@ -3,9 +3,12 @@ using GitHub.Models; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.Dialog { - public interface IRepositoryCreationViewModel : IRepositoryForm, IRepositoryCreationTarget + public interface IRepositoryCreationViewModel : IDialogContentViewModel, + IConnectionInitializedViewModel, + IRepositoryForm, + IRepositoryCreationTarget { /// /// Command that creates the repository. diff --git a/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryRecloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryRecloneViewModel.cs new file mode 100644 index 0000000000..9feef83bf2 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Dialog/IRepositoryRecloneViewModel.cs @@ -0,0 +1,13 @@ +using System; +using GitHub.Models; + +namespace GitHub.ViewModels.Dialog +{ + public interface IRepositoryRecloneViewModel : IDialogContentViewModel, IConnectionInitializedViewModel + { + /// + /// Gets or sets the repository to clone. + /// + IRepositoryModel SelectedRepository { get; set; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoggedOutViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoggedOutViewModel.cs similarity index 94% rename from src/GitHub.Exports.Reactive/ViewModels/ILoggedOutViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoggedOutViewModel.cs index 664bfc83f3..9a615c4377 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoggedOutViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ILoggedOutViewModel.cs @@ -2,7 +2,7 @@ using System.Reactive; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// Defines the view model for the "Sign in to GitHub" view in the GitHub pane. diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INavigationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INavigationViewModel.cs new file mode 100644 index 0000000000..65b3dfb43b --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INavigationViewModel.cs @@ -0,0 +1,74 @@ +using System; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// A view model that supports back/forward navigation of an inner content page. + /// + public interface INavigationViewModel : IViewModel + { + /// + /// Gets or sets the current content page. + /// + IPanePageViewModel Content { get; } + + /// + /// Gets or sets the current index in the history list. + /// + int Index { get; set; } + + /// + /// Gets the back and forward history. + /// + IReadOnlyReactiveList History { get; } + + /// + /// Gets a command that navigates back in the history. + /// + ReactiveCommand NavigateBack { get; } + + /// + /// Gets a command that navigates forwards in the history. + /// + ReactiveCommand NavigateForward { get; } + + /// + /// Navigates back if possible. + /// + /// True if there was a page to navigate back to. + bool Back(); + + /// + /// Clears the current page and all history . + /// + void Clear(); + + /// + /// Navigates forwards if possible. + /// + /// True if there was a page to navigate forwards to. + bool Forward(); + + /// + /// Navigates to a new page. + /// + /// The page view model. + void NavigateTo(IPanePageViewModel page); + + /// + /// Registers a resource for disposal when all instances of a page are removed from the + /// history. + /// + /// The page. + /// The resource to dispose. + void RegisterDispose(IPanePageViewModel page, IDisposable dispose); + + /// + /// Removes all instances of a page from the history. + /// + /// The page to remove. + int RemoveAll(IPanePageViewModel page); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/INotAGitHubRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitHubRepositoryViewModel.cs similarity index 91% rename from src/GitHub.Exports.Reactive/ViewModels/INotAGitHubRepositoryViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitHubRepositoryViewModel.cs index 1039cdcfee..c96b9faf54 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/INotAGitHubRepositoryViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitHubRepositoryViewModel.cs @@ -1,6 +1,6 @@ using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// Defines the view model for the "Sign in to GitHub" view in the GitHub pane. diff --git a/src/GitHub.Exports.Reactive/ViewModels/INotAGitRepositoryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitRepositoryViewModel.cs similarity index 57% rename from src/GitHub.Exports.Reactive/ViewModels/INotAGitRepositoryViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitRepositoryViewModel.cs index e34a5c4f17..296673bc5d 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/INotAGitRepositoryViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/INotAGitRepositoryViewModel.cs @@ -1,9 +1,11 @@ -namespace GitHub.ViewModels +using System.Diagnostics.CodeAnalysis; + +namespace GitHub.ViewModels.GitHubPane { /// /// Defines the view model for the "Not a git repository" view in the GitHub pane. /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces")] + [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces")] public interface INotAGitRepositoryViewModel : IPanePageViewModel { } diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPanePageViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPanePageViewModel.cs new file mode 100644 index 0000000000..16c76d1d45 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPanePageViewModel.cs @@ -0,0 +1,56 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// A view model that represents a page in the GitHub pane. + /// + public interface IPanePageViewModel : IViewModel + { + /// + /// Gets an exception representing an error state to display. + /// + Exception Error { get; } + + /// + /// Gets a value indicating whether the page is busy. + /// + /// + /// When is set to true, an indeterminate progress bar will be + /// displayed at the top of the GitHub pane but the pane contents will remain visible. + /// + bool IsBusy { get; } + + /// + /// Gets a value indicating whether the page is loading. + /// + /// + /// When is set to true, a spinner will be displayed instead of the + /// pane contents. + /// + bool IsLoading { get; } + + /// + /// Gets the title to display in the pane when the page is shown. + /// + string Title { get; } + + /// + /// Gets an observable that is fired when the pane wishes to close itself. + /// + IObservable CloseRequested { get; } + + /// + /// Gets an observable that is fired with a URI when the pane wishes to navigate to another + /// pane. + /// + IObservable NavigationRequested { get; } + + /// + /// Refreshes the view model. + /// + Task Refresh(); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestChangeNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs similarity index 91% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestChangeNode.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs index 8a343a002c..2661367609 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestChangeNode.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs @@ -1,6 +1,6 @@ using System; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// Represents a file or directory node in a pull request changes tree. diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs similarity index 60% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestCreationViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs index bc38638250..5452675dc2 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestCreationViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs @@ -2,16 +2,20 @@ using System.Collections.Generic; using GitHub.Validation; using ReactiveUI; +using System.Threading.Tasks; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { - public interface IPullRequestCreationViewModel : IDialogViewModel, IPanePageViewModel + public interface IPullRequestCreationViewModel : IPanePageViewModel { IBranch SourceBranch { get; set; } IBranch TargetBranch { get; set; } IReadOnlyList Branches { get; } IReactiveCommand CreatePullRequest { get; } + IReactiveCommand Cancel { get; } string PRTitle { get; set; } ReactivePropertyValidator TitleValidator { get; } + + Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection); } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDetailViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs similarity index 88% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestDetailViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs index 5397b256a3..078692d948 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDetailViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs @@ -1,13 +1,12 @@ using System; using System.Collections.Generic; using System.Reactive; -using System.Text; using System.Threading.Tasks; using GitHub.Models; using GitHub.Services; using ReactiveUI; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// Holds immutable state relating to the command. @@ -65,7 +64,7 @@ public interface IPullRequestUpdateState /// /// Represents a view model for displaying details of a pull request. /// - public interface IPullRequestDetailViewModel : IViewModel, IHasLoading, IHasBusy, IHasErrorState + public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowser { /// /// Gets the underlying pull request model. @@ -91,6 +90,11 @@ public interface IPullRequestDetailViewModel : IViewModel, IHasLoading, IHasBusy /// string RemoteRepositoryOwner { get; } + /// + /// Gets the Pull Request number. + /// + int Number { get; } + /// /// Gets a string describing how to display the pull request's source branch. /// @@ -182,6 +186,21 @@ public interface IPullRequestDetailViewModel : IViewModel, IHasLoading, IHasBusy /// ReactiveCommand ViewFile { get; } + /// + /// Initializes the view model. + /// + /// The local repository. + /// The connection to the repository host. + /// The pull request's repository owner. + /// The pull request's repository name. + /// The pull request number. + Task InitializeAsync( + ILocalRepositoryModel localRepository, + IConnection connection, + string owner, + string repo, + int number); + /// /// Gets a file as it appears in the pull request. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDirectoryNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDirectoryNode.cs similarity index 93% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestDirectoryNode.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDirectoryNode.cs index 6b227c3ff9..7e2b23c1c3 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestDirectoryNode.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDirectoryNode.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// Represents a directory node in a pull request changes tree. diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFileNode.cs similarity index 96% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileNode.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFileNode.cs index c9c2b21ec6..ef3c30f75e 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileNode.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFileNode.cs @@ -1,6 +1,6 @@ using GitHub.Models; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { public interface IPullRequestFileNode : IPullRequestChangeNode { diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs similarity index 83% rename from src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs index d5385d7ba8..0c1b3f6ff8 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestListViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs @@ -1,11 +1,11 @@ using System.Collections.Generic; using GitHub.Collections; using GitHub.Models; -using System.Windows.Input; using ReactiveUI; using System.Collections.ObjectModel; +using System.Threading.Tasks; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { public class PullRequestState { @@ -27,7 +27,7 @@ public override string ToString() } } - public interface IPullRequestListViewModel : ISearchablePanePageViewModel, ICanNavigate, IHasBusy + public interface IPullRequestListViewModel : ISearchablePageViewModel, IOpenInBrowser { IReadOnlyList Repositories { get; } IRemoteRepositoryModel SelectedRepository { get; set; } @@ -42,5 +42,7 @@ public interface IPullRequestListViewModel : ISearchablePanePageViewModel, ICanN ReactiveCommand OpenPullRequest { get; } ReactiveCommand CreatePullRequest { get; } ReactiveCommand OpenPullRequestOnGitHub { get; } + + Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection); } } diff --git a/src/GitHub.Exports/ViewModels/ISearchablePageViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ISearchablePageViewModel.cs similarity index 72% rename from src/GitHub.Exports/ViewModels/ISearchablePageViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ISearchablePageViewModel.cs index a35ac59ad3..808bdc1e92 100644 --- a/src/GitHub.Exports/ViewModels/ISearchablePageViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/ISearchablePageViewModel.cs @@ -1,11 +1,11 @@ using System; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.GitHubPane { /// /// A view model that represents a searchable page in the GitHub pane. /// - public interface ISearchablePanePageViewModel : IPanePageViewModel + public interface ISearchablePageViewModel : IPanePageViewModel { /// /// Gets or sets the current search query. diff --git a/src/GitHub.Exports.Reactive/ViewModels/IBaseCloneViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IBaseCloneViewModel.cs deleted file mode 100644 index 62a92bced5..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IBaseCloneViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using System.Reactive; -using GitHub.Models; -using ReactiveUI; -using System.Collections.ObjectModel; - -namespace GitHub.ViewModels -{ - /// - /// ViewModel for the the Clone Repository dialog - /// - public interface IBaseCloneViewModel : IDialogViewModel, IRepositoryCreationTarget - { - /// - /// Signals that the user clicked the clone button. - /// - IReactiveCommand CloneCommand { get; } - - IRepositoryModel SelectedRepository { get; set; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IDialogViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IDialogViewModel.cs deleted file mode 100644 index e76ba4c1ea..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IDialogViewModel.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Reactive; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - /// - /// A which exposes its Cancel command as a reactive command. - /// - public interface IDialogViewModel : IViewModel, IHasDone, IHasCancel - { - /// - /// Gets a title to display in the dialog titlebar. - /// - string Title { get; } - - /// - /// Gets a value indicating whether the view model is busy. - /// - bool IsBusy { get; } - - /// - /// Gets a value indicating whether the view model represents the page currently being shown. - /// - bool IsShowing { get; } - - /// - /// Gets a command that will dismiss the page. - /// - new ReactiveCommand Cancel { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IHasCancel.cs b/src/GitHub.Exports.Reactive/ViewModels/IHasCancel.cs deleted file mode 100644 index 49aaea5c5f..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IHasCancel.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Reactive; - -namespace GitHub.ViewModels -{ - /// - /// Represents a view model that has a "Cancel" signal. - /// - public interface IHasCancel - { - /// - /// Gets an observable which will emit a value when the view model is cancelled. - /// - IObservable Cancel { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IHasDone.cs b/src/GitHub.Exports.Reactive/ViewModels/IHasDone.cs deleted file mode 100644 index 7b04195633..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IHasDone.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Reactive; - -namespace GitHub.ViewModels -{ - /// - /// Represents a view model that has a "Done" signal. - /// - public interface IHasDone - { - /// - /// Gets an observable which will emit a value when the view model is done. - /// - IObservable Done { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginControlViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoginControlViewModel.cs deleted file mode 100644 index 25b7ba36d7..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginControlViewModel.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using ReactiveUI; - -namespace GitHub.ViewModels -{ - [Flags] - public enum LoginMode - { - None = 0x00, - DotComOnly = 0x01, - EnterpriseOnly = 0x02, - DotComOrEnterprise = 0x03, - } - - /// - /// Represents a view model responsible for providing log in to - /// either GitHub.com or a GitHub Enterprise instance. - /// - public interface ILoginControlViewModel : IReactiveObject, ILoginViewModel - { - /// - /// Gets a value indicating the currently available login modes - /// for the control. - /// - LoginMode LoginMode { get; } - - /// - /// Gets a value indicating whether this instance is currently - /// in the process of logging in. - /// - bool IsLoginInProgress { get; } - - /// - /// Gets a view model responsible for authenticating a user - /// against GitHub.com. - /// - ILoginToGitHubViewModel GitHubLogin { get; } - - /// - /// Gets a view model responsible for authenticating a user - /// against a GitHub Enterprise instance. - /// - ILoginToGitHubForEnterpriseViewModel EnterpriseLogin { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs index 2ea024b516..69a2ad2f39 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ILoginToHostViewModel.cs @@ -1,5 +1,5 @@ using System.Reactive; -using GitHub.Authentication; +using GitHub.Models; using GitHub.Validation; using ReactiveUI; @@ -33,7 +33,7 @@ public interface ILoginToHostViewModel /// Gets a command which, when invoked, performs the actual /// login procedure. /// - IReactiveCommand Login { get; } + IReactiveCommand Login { get; } /// /// Gets a command which, when invoked, direct the user to a diff --git a/src/GitHub.Exports.Reactive/ViewModels/ILoginViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ILoginViewModel.cs deleted file mode 100644 index 12dc173ca0..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/ILoginViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using GitHub.Authentication; - -namespace GitHub.ViewModels -{ - public interface ILoginViewModel : IDialogViewModel - { - /// - /// Gets an observable sequence which produces an authentication - /// result every time a log in attempt through this control success - /// or fails. - /// - IObservable AuthenticationResults { get; } - } -} diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileViewModel.cs deleted file mode 100644 index 3d1dd147bc..0000000000 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestFileViewModel.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace GitHub.ViewModels -{ - /// - /// Describes the way in which a file is changed in a pull request. - /// - public enum FileChangeType - { - /// - /// The file contents were changed. - /// - Changed, - - /// - /// The file was added. - /// - Added, - - /// - /// The file was deleted. - /// - Removed, - } - - /// - /// Represents a file node in a pull request changes tree. - /// - public interface IPullRequestFileViewModel : IPullRequestChangeNode - { - /// - /// Gets the type of change that was made to the file. - /// - FileChangeType ChangeType { get; } - - /// - /// Gets the name of the file without path information. - /// - string FileName { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryPublishViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/TeamExplorer/IRepositoryPublishViewModel.cs similarity index 73% rename from src/GitHub.Exports.Reactive/ViewModels/IRepositoryPublishViewModel.cs rename to src/GitHub.Exports.Reactive/ViewModels/TeamExplorer/IRepositoryPublishViewModel.cs index cb188876b2..5cb230dad1 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IRepositoryPublishViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/TeamExplorer/IRepositoryPublishViewModel.cs @@ -1,15 +1,18 @@ -using System.Reactive; +using GitHub.Extensions; using GitHub.Models; using ReactiveUI; -using System.Collections.ObjectModel; -using GitHub.Extensions; -namespace GitHub.ViewModels +namespace GitHub.ViewModels.TeamExplorer { - public interface IRepositoryPublishViewModel : IRepositoryForm + public interface IRepositoryPublishViewModel : IViewModel, IRepositoryForm { IReadOnlyObservableCollection Connections { get; } + /// + /// Gets the busy state of the publish. + /// + bool IsBusy { get; } + /// /// Command that creates the repository. /// @@ -33,5 +36,4 @@ public enum ProgressState Success, Fail } - } diff --git a/src/GitHub.App/ViewModels/ViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs similarity index 59% rename from src/GitHub.App/ViewModels/ViewModelBase.cs rename to src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs index 503b80a711..be2bb1780a 100644 --- a/src/GitHub.App/ViewModels/ViewModelBase.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ViewModelBase.cs @@ -7,11 +7,11 @@ namespace GitHub.ViewModels /// /// Base class for view models. /// + /// + /// A view model must inherit from this class in order for a view to be automatically + /// found by the ViewLocator. + /// public abstract class ViewModelBase : ReactiveObject, IViewModel { - /// - public virtual void Initialize(ViewWithData data) - { - } } } diff --git a/src/GitHub.Exports/Authentication/AuthenticationResult.cs b/src/GitHub.Exports/Authentication/AuthenticationResult.cs deleted file mode 100644 index f2623e2774..0000000000 --- a/src/GitHub.Exports/Authentication/AuthenticationResult.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace GitHub.Authentication -{ - public enum AuthenticationResult - { - /// - /// Could not authenticate using the credentials provided. - /// - CredentialFailure, - /// - /// The two factor authentication challenge failed. - /// - VerificationFailure, - /// - /// The given remote Uri is not an enterprise Uri. - /// - EnterpriseServerNotFound, - /// - /// Aaaawwww yeeeaah - /// - Success - } -} diff --git a/src/GitHub.Exports/Authentication/AuthenticationResultExtensions.cs b/src/GitHub.Exports/Authentication/AuthenticationResultExtensions.cs deleted file mode 100644 index 3a57e63c61..0000000000 --- a/src/GitHub.Exports/Authentication/AuthenticationResultExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace GitHub.Authentication -{ - - public static class AuthenticationResultExtensions - { - public static bool IsFailure(this AuthenticationResult result) - { - return result != AuthenticationResult.Success; - } - - public static bool IsSuccess(this AuthenticationResult result) - { - return result == AuthenticationResult.Success; - } - } -} diff --git a/src/GitHub.Exports/Exports/ExportMetadata.cs b/src/GitHub.Exports/Exports/ExportMetadata.cs index 8960d2596e..1b89959111 100644 --- a/src/GitHub.Exports/Exports/ExportMetadata.cs +++ b/src/GitHub.Exports/Exports/ExportMetadata.cs @@ -1,39 +1,13 @@ using System; using System.ComponentModel.Composition; -using GitHub.UI; -using GitHub.ViewModels; -using System.Windows.Controls; +using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Diagnostics; using System.Reflection; +using System.Windows; using GitHub.VisualStudio; namespace GitHub.Exports { - /// - /// Defines the type of a view or view model. - /// - public enum UIViewType - { - None, - End, - Login, - TwoFactor, - Create, - Clone, - Publish, - Gist, - PRList, - PRDetail, - PRCreation, - GitHubPane, - LoggedOut, - NotAGitRepository, - NotAGitHubRepository, - StartPageClone, - - } - /// /// Defines the types of global visual studio menus available. /// @@ -53,32 +27,20 @@ public enum LinkType } /// - /// A MEF export attribute that defines an export of type with - /// metadata. - /// - [MetadataAttribute] - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public sealed class ExportViewModelAttribute : ExportAttribute - { - public ExportViewModelAttribute() : base(typeof(IViewModel)) - {} - - public UIViewType ViewType { get; set; } - } - - /// - /// A MEF export attribute that defines an export of type with - /// metadata. + /// A MEF export attribute that defines an export of type with + /// metadata. /// [MetadataAttribute] - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public sealed class ExportViewAttribute : ExportAttribute + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public sealed class ExportViewForAttribute : ExportAttribute { - public ExportViewAttribute() : base(typeof(IView)) + public ExportViewForAttribute(Type viewModelType) + : base(typeof(FrameworkElement)) { + ViewModelType = viewModelType; } - public UIViewType ViewType { get; set; } + public Type ViewModelType { get; } } /// @@ -97,8 +59,8 @@ public ExportMenuAttribute() : base(typeof(IMenuHandler)) } /// - /// Defines a MEF metadata view that matches and - /// . + /// Defines a MEF metadata view that matches and + /// . /// /// /// For more information see the Metadata and Metadata views section at @@ -106,7 +68,8 @@ public ExportMenuAttribute() : base(typeof(IMenuHandler)) /// public interface IViewModelMetadata { - UIViewType ViewType { get; } + [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays")] + Type[] ViewModelType { get; } } /// @@ -123,27 +86,6 @@ public interface IMenuMetadata public static class ExportMetadataAttributeExtensions { - public static bool IsViewType(this UserControl c, UIViewType type) - { - return c.GetType().GetCustomAttributesData().Any(attr => IsViewType(attr, type)); - } - - public static bool IsViewType(this IView c, UIViewType type) - { - return c.GetType().GetCustomAttributesData().Any(attr => IsViewType(attr, type)); - } - - static bool IsViewType(CustomAttributeData attributeData, UIViewType viewType) - { - if (attributeData.NamedArguments == null) - { - throw new GitHubLogicException("attributeData.NamedArguments may not be null"); - } - - return attributeData.AttributeType == typeof(ExportViewAttribute) - && (UIViewType)attributeData.NamedArguments[0].TypedValue.Value == viewType; - } - public static bool IsMenuType(this IMenuHandler c, MenuType type) { return c.GetType().GetCustomAttributesData().Any(attr => IsMenuType(attr, type)); diff --git a/src/GitHub.Exports/Factories/IUIFactory.cs b/src/GitHub.Exports/Factories/IUIFactory.cs deleted file mode 100644 index b24b153d5e..0000000000 --- a/src/GitHub.Exports/Factories/IUIFactory.cs +++ /dev/null @@ -1,10 +0,0 @@ -using GitHub.Exports; -using System; - -namespace GitHub.App.Factories -{ - public interface IUIFactory : IDisposable - { - IUIPair CreateViewAndViewModel(UIViewType viewType); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/Factories/IUIPair.cs b/src/GitHub.Exports/Factories/IUIPair.cs deleted file mode 100644 index acdbb5ea55..0000000000 --- a/src/GitHub.Exports/Factories/IUIPair.cs +++ /dev/null @@ -1,14 +0,0 @@ -using GitHub.UI; -using GitHub.ViewModels; -using System; - -namespace GitHub.App.Factories -{ - public interface IUIPair : IDisposable - { - IView View { get; } - IViewModel ViewModel { get; } - void AddHandler(IDisposable disposable); - void ClearHandlers(); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/Factories/IViewViewModelFactory.cs b/src/GitHub.Exports/Factories/IViewViewModelFactory.cs new file mode 100644 index 0000000000..70464d1943 --- /dev/null +++ b/src/GitHub.Exports/Factories/IViewViewModelFactory.cs @@ -0,0 +1,35 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Windows; +using GitHub.ViewModels; + +namespace GitHub.Factories +{ + /// + /// Factory for creating views and view models. + /// + public interface IViewViewModelFactory + { + /// + /// Creates a view model based on the specified interface type. + /// + /// The view model interface type. + /// The view model. + TViewModel CreateViewModel() where TViewModel : IViewModel; + + /// + /// Creates a view based on a view model interface type. + /// + /// The view model interface type. + /// The view. + [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter")] + FrameworkElement CreateView() where TViewModel : IViewModel; + + /// + /// Creates a view based on a view model interface type. + /// + /// The view model interface type. + /// The view. + FrameworkElement CreateView(Type viewModel); + } +} diff --git a/src/GitHub.Exports/GitHub.Exports.csproj b/src/GitHub.Exports/GitHub.Exports.csproj index 52f077afd8..556b569cfb 100644 --- a/src/GitHub.Exports/GitHub.Exports.csproj +++ b/src/GitHub.Exports/GitHub.Exports.csproj @@ -149,17 +149,19 @@ + + + - - - - - + + + + Properties\settings.json @@ -167,8 +169,6 @@ - - @@ -183,7 +183,7 @@ - + @@ -207,7 +207,6 @@ - @@ -223,7 +222,6 @@ - @@ -238,11 +236,7 @@ - - - - @@ -250,20 +244,14 @@ - - - - - - diff --git a/src/GitHub.Exports/Models/IExportFactoryProvider.cs b/src/GitHub.Exports/Models/IExportFactoryProvider.cs deleted file mode 100644 index 7ea444d020..0000000000 --- a/src/GitHub.Exports/Models/IExportFactoryProvider.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GitHub.Exports; -using GitHub.UI; -using GitHub.ViewModels; -using System.Collections.Generic; -using System.ComponentModel.Composition; - -namespace GitHub.Models -{ - public interface IExportFactoryProvider - { - IEnumerable> ViewModelFactory { get; set; } - IEnumerable> ViewFactory { get; set; } - ExportLifetimeContext GetViewModel(UIViewType viewType); - ExportLifetimeContext GetView(UIViewType viewType); - } -} diff --git a/src/GitHub.Exports/Services/ExportFactoryProvider.cs b/src/GitHub.Exports/Services/ExportFactoryProvider.cs deleted file mode 100644 index 525979119a..0000000000 --- a/src/GitHub.Exports/Services/ExportFactoryProvider.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using GitHub.Exports; -using GitHub.UI; -using GitHub.ViewModels; -using GitHub.Models; - -namespace GitHub.Services -{ - [Export(typeof(IExportFactoryProvider))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class ExportFactoryProvider : IExportFactoryProvider - { - [ImportingConstructor] - public ExportFactoryProvider(ICompositionService cc) - { - cc.SatisfyImportsOnce(this); - } - - [ImportMany(AllowRecomposition = true)] - public IEnumerable> ViewModelFactory { get; set; } - - [ImportMany(AllowRecomposition = true)] - public IEnumerable> ViewFactory { get; set; } - - public ExportLifetimeContext GetViewModel(UIViewType viewType) - { - if (ViewModelFactory == null) - { - throw new GitHubLogicException("Attempted to obtain a view model before we imported the ViewModelFactory"); - } - - var f = ViewModelFactory.FirstOrDefault(x => x.Metadata.ViewType == viewType); - - if (f == null) - { - throw new GitHubLogicException(string.Format(CultureInfo.InvariantCulture, "Could not locate view model for {0}.", viewType)); - } - - return f.CreateExport(); - } - - public ExportLifetimeContext GetView(UIViewType viewType) - { - var f = ViewFactory.FirstOrDefault(x => x.Metadata.ViewType == viewType); - - if (f == null) - { - throw new GitHubLogicException(string.Format(CultureInfo.InvariantCulture, "Could not locate view for {0}.", viewType)); - } - - return f.CreateExport(); - } - } -} diff --git a/src/GitHub.Exports/Services/IDialogService.cs b/src/GitHub.Exports/Services/IDialogService.cs index d05f1abd7f..0c3cf4603a 100644 --- a/src/GitHub.Exports/Services/IDialogService.cs +++ b/src/GitHub.Exports/Services/IDialogService.cs @@ -10,9 +10,12 @@ namespace GitHub.Services public interface IDialogService { /// - /// Shows the clone dialog. + /// Shows the Clone dialog. /// - /// The connection to use. + /// + /// The connection to use. If null, the first connection will be used, or the user promted + /// to log in if there are no connections. + /// /// /// A task that returns an instance of on success, /// or null if the dialog was cancelled. @@ -32,5 +35,27 @@ public interface IDialogService /// out a repository that was previously checked out on another machine. /// Task ShowReCloneDialog(IRepositoryModel repository); + + /// + /// Shows the Create Gist dialog. + /// + Task ShowCreateGist(); + + /// + /// Shows the Create Repository dialog. + /// + /// + /// The connection to use. May not be null. + /// + Task ShowCreateRepositoryDialog(IConnection connection); + + /// + /// Shows the Login dialog. + /// + /// + /// The created by the login, or null if the login was + /// unsuccessful. + /// + Task ShowLoginDialog(); } } diff --git a/src/GitHub.Exports/Services/IUIProvider.cs b/src/GitHub.Exports/Services/IUIProvider.cs deleted file mode 100644 index 34a138eb29..0000000000 --- a/src/GitHub.Exports/Services/IUIProvider.cs +++ /dev/null @@ -1,21 +0,0 @@ -using GitHub.Exports; -using GitHub.Models; -using GitHub.UI; -using GitHub.VisualStudio; -using System; -using System.Runtime.InteropServices; - -namespace GitHub.Services -{ - [Guid(Guids.UIProviderId)] - public interface IUIProvider - { - IUIController Configure(UIControllerFlow flow, IConnection connection = null, ViewWithData data = null); - IUIController Run(UIControllerFlow flow); - void RunInDialog(UIControllerFlow flow, IConnection connection = null); - void RunInDialog(IUIController controller); - IView GetView(UIViewType which, ViewWithData data = null); - void StopUI(IUIController controller); - void Run(IUIController controller); - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/UI/IUIController.cs b/src/GitHub.Exports/UI/IUIController.cs deleted file mode 100644 index 4140bc2d47..0000000000 --- a/src/GitHub.Exports/UI/IUIController.cs +++ /dev/null @@ -1,85 +0,0 @@ -using GitHub.Exports; -using GitHub.Models; -using System; -using GitHub.ViewModels; - -namespace GitHub.UI -{ - public interface IUIController : IDisposable - { - /// - /// Allows listening to the completion state of the ui flow - whether - /// it was completed because it was cancelled or whether it succeeded. - /// - /// true for success, false for cancel - IObservable ListenToCompletionState(); - void Start(); - void Stop(); - bool IsStopped { get; } - UIControllerFlow CurrentFlow { get; } - UIControllerFlow SelectedFlow { get; } - IObservable TransitionSignal { get; } - - IObservable Configure(UIControllerFlow choice, IConnection connection = null, ViewWithData parameters = null); - void Reload(); - } - - public enum UIControllerFlow - { - None = 0, - Authentication, - Create, - Clone, - Publish, - Gist, - Home, - ReClone, - PullRequestList, - PullRequestDetail, - PullRequestCreation, - } - - public class ViewWithData - { - public UIControllerFlow ActiveFlow; - public UIControllerFlow MainFlow; - public UIViewType ViewType; - public object Data; - - public ViewWithData() {} - public ViewWithData(UIControllerFlow flow) - { - ActiveFlow = flow; - MainFlow = flow; - } - } - - public struct LoadData - { - public UIControllerFlow Flow; - public IView View; - public ViewWithData Data; - - public override int GetHashCode() - { - return 17 * (23 + Flow.GetHashCode()) * (23 + (View?.GetHashCode() ?? 0)) * (23 + (Data?.GetHashCode() ?? 0)); - } - - public override bool Equals(object obj) - { - if (obj is LoadData) - return GetHashCode() == obj.GetHashCode(); - return base.Equals(obj); - } - - public static bool operator==(LoadData lhs, LoadData rhs) - { - return lhs.Equals(rhs); - } - - public static bool operator !=(LoadData lhs, LoadData rhs) - { - return !lhs.Equals(rhs); - } - } -} diff --git a/src/GitHub.Exports/UI/IView.cs b/src/GitHub.Exports/UI/IView.cs deleted file mode 100644 index 9118ea7be1..0000000000 --- a/src/GitHub.Exports/UI/IView.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using GitHub.ViewModels; - -namespace GitHub.UI -{ - /// - /// Base interface for all views. - /// - public interface IView - { - /// - /// Gets the view model associated with the view. - /// - IViewModel ViewModel { get; } - - /// - /// Gets or sets the WPF DataContext for the view. - /// - object DataContext { get; set; } - } -} diff --git a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs new file mode 100644 index 0000000000..5b6771c4d3 --- /dev/null +++ b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubPaneViewModel.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Describes the ways that the display of can + /// be overridden. + /// + public enum ContentOverride + { + /// + /// No override, display the content. + /// + None, + + /// + /// Display a spinner instead of the content. + /// + Spinner, + + /// + /// Display an error instead of the content. + /// + Error + } + + /// + /// The view model for the GitHub Pane. + /// + public interface IGitHubPaneViewModel : IViewModel + { + /// + /// Gets the connection to the current repository. + /// + IConnection Connection { get; } + + /// + /// Gets the content to display in the GitHub pane. + /// + IViewModel Content { get; } + + /// + /// Gets a value describing whether to display the or to override + /// it with another view. + /// + ContentOverride ContentOverride { get; } + + /// + /// Gets a value indicating whether search is available on the current page. + /// + bool IsSearchEnabled { get; } + + /// + /// Gets the local repository. + /// + ILocalRepositoryModel LocalRepository { get; } + + /// + /// Gets or sets the search query for the current page. + /// + string SearchQuery { get; set; } + + /// + /// Gets the title to display in the GitHub pane header. + /// + string Title { get; } + + /// + /// Initializes the view model. + /// + Task InitializeAsync(IServiceProvider paneServiceProvider); + + /// + /// Navigates to a GitHub Pane URL. + /// + /// The URL. + Task NavigateTo(Uri uri); + + /// + /// Shows the pull reqest list in the GitHub pane. + /// + Task ShowPullRequests(); + + /// + /// Shows the details for a pull request in the GitHub pane. + /// + /// The repository owner. + /// The repository name. + /// The pull rqeuest number. + Task ShowPullRequest(string owner, string repo, int number); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/ICanNavigate.cs b/src/GitHub.Exports/ViewModels/ICanNavigate.cs deleted file mode 100644 index ac740fbfdd..0000000000 --- a/src/GitHub.Exports/ViewModels/ICanNavigate.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using GitHub.UI; - -namespace GitHub.ViewModels -{ - /// - /// Interface for view models in a navigable panel such as the GitHub pane which can signal - /// to navigate to another page. - /// - public interface ICanNavigate - { - /// - /// Gets an observable which is signalled with the page to navigate to. - /// - IObservable Navigate { get; } - } -} diff --git a/src/GitHub.Exports/ViewModels/IConnectionInitializedViewModel.cs b/src/GitHub.Exports/ViewModels/IConnectionInitializedViewModel.cs new file mode 100644 index 0000000000..4889067b7f --- /dev/null +++ b/src/GitHub.Exports/ViewModels/IConnectionInitializedViewModel.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels +{ + /// + /// Represents a view model that requires initialization with a connection. + /// + public interface IConnectionInitializedViewModel : IViewModel + { + /// + /// Initializes the view model with the specified connection. + /// + /// The connection. + /// A task tracking the initialization. + Task InitializeAsync(IConnection connection); + } +} diff --git a/src/GitHub.Exports/ViewModels/IGitHubPaneViewModel.cs b/src/GitHub.Exports/ViewModels/IGitHubPaneViewModel.cs deleted file mode 100644 index 29935099ba..0000000000 --- a/src/GitHub.Exports/ViewModels/IGitHubPaneViewModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using GitHub.UI; - -namespace GitHub.ViewModels -{ - public interface IGitHubPaneViewModel : IViewModel - { - string ActiveRepoName { get; } - IView Control { get; } - string Message { get; } - MessageType MessageType { get; } - - /// - /// Gets a value indicating whether search is available on the current page. - /// - bool IsSearchEnabled { get; } - - /// - /// Gets or sets the search query for the current page. - /// - string SearchQuery { get; set; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/IHasBusy.cs b/src/GitHub.Exports/ViewModels/IHasBusy.cs deleted file mode 100644 index c15e4c2e14..0000000000 --- a/src/GitHub.Exports/ViewModels/IHasBusy.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; - -namespace GitHub.ViewModels -{ - /// - /// Interface for view models that have a busy state. - /// - /// - /// is similar to but they represent - /// different states: - /// - When is true: There is no data to display. - /// - When is true: There is data to display but that data is - /// being updated or is in the process of being loaded. - /// - public interface IHasBusy - { - bool IsBusy { get; } - } -} diff --git a/src/GitHub.Exports/ViewModels/IHasErrorState.cs b/src/GitHub.Exports/ViewModels/IHasErrorState.cs deleted file mode 100644 index effda2e232..0000000000 --- a/src/GitHub.Exports/ViewModels/IHasErrorState.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace GitHub.ViewModels -{ - /// - /// Interface for view models that have an error state. - /// - public interface IHasErrorState - { - /// - /// Gets the view model's error message or null if the view model is not in an error state. - /// - string ErrorMessage { get; } - } -} diff --git a/src/GitHub.Exports/ViewModels/IHasLoading.cs b/src/GitHub.Exports/ViewModels/IHasLoading.cs deleted file mode 100644 index 2d7e57908b..0000000000 --- a/src/GitHub.Exports/ViewModels/IHasLoading.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; - -namespace GitHub.ViewModels -{ - /// - /// Interface for view models that have a busy state. - /// - /// - /// is similar to but they represent - /// different states: - /// - When is true: There is no data to display. - /// - When is true: There is data to display but that data is - /// being updated or is in the process of being loaded. - /// - public interface IHasLoading - { - bool IsLoading { get; } - } -} diff --git a/src/GitHub.Exports/ViewModels/IOpenInBrowser.cs b/src/GitHub.Exports/ViewModels/IOpenInBrowser.cs new file mode 100644 index 0000000000..7eaaeb4828 --- /dev/null +++ b/src/GitHub.Exports/ViewModels/IOpenInBrowser.cs @@ -0,0 +1,15 @@ +using System; + +namespace GitHub.ViewModels +{ + /// + /// Represents a view model with a URL that can be opened in the system web browser. + /// + public interface IOpenInBrowser + { + /// + /// Gets the URL. + /// + Uri WebUrl { get; } + } +} diff --git a/src/GitHub.Exports/ViewModels/IPanePageViewModel.cs b/src/GitHub.Exports/ViewModels/IPanePageViewModel.cs deleted file mode 100644 index ef926690a5..0000000000 --- a/src/GitHub.Exports/ViewModels/IPanePageViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace GitHub.ViewModels -{ - /// - /// A view model that represents a page in the GitHub pane. - /// - public interface IPanePageViewModel : IViewModel - { - /// - /// Gets the title to display in the pane when the page is shown. - /// - string Title { get; } - } -} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs b/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs index 33a1c6ac44..b207a198ad 100644 --- a/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs +++ b/src/GitHub.Exports/ViewModels/IServiceProviderAware.cs @@ -1,5 +1,4 @@ -using GitHub.UI; -using System; +using System; namespace GitHub.ViewModels { @@ -7,9 +6,4 @@ public interface IServiceProviderAware { void Initialize(IServiceProvider serviceProvider); } - - public interface IViewHost - { - void ShowView(ViewWithData data); - } } diff --git a/src/GitHub.Exports/ViewModels/IViewModel.cs b/src/GitHub.Exports/ViewModels/IViewModel.cs index 6445e7461b..1137370652 100644 --- a/src/GitHub.Exports/ViewModels/IViewModel.cs +++ b/src/GitHub.Exports/ViewModels/IViewModel.cs @@ -1,18 +1,14 @@ using System; using System.ComponentModel; -using GitHub.UI; +using System.Diagnostics.CodeAnalysis; namespace GitHub.ViewModels { /// /// Base interface for all view models. /// + [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces")] public interface IViewModel : INotifyPropertyChanged { - /// - /// Initializes the view model. - /// - /// An object containing the related view and the data to load. - void Initialize(ViewWithData data); } } diff --git a/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs b/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs index 3c7ee3891f..2a158c45ae 100644 --- a/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs +++ b/src/GitHub.InlineReviews/SampleData/CommentViewModelDesigner.cs @@ -17,10 +17,6 @@ public CommentViewModelDesigner() User = new AccountDesigner { Login = "shana", IsUser = true }; } - public void Initialize(ViewWithData data) - { - } - public int Id { get; set; } public string Body { get; set; } public string ErrorMessage { get; set; } diff --git a/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs b/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs index 3e298d1843..af5982e418 100644 --- a/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs +++ b/src/GitHub.InlineReviews/ViewModels/CommentViewModel.cs @@ -97,11 +97,6 @@ public CommentViewModel( { } - public void Initialize(ViewWithData data) - { - // Nothing to do here: initialized in constructor. - } - /// /// Creates a placeholder comment which can be used to add a new comment to a thread. /// diff --git a/src/GitHub.InlineReviews/Views/CommentView.xaml.cs b/src/GitHub.InlineReviews/Views/CommentView.xaml.cs index b3a7b17671..454f7f3f39 100644 --- a/src/GitHub.InlineReviews/Views/CommentView.xaml.cs +++ b/src/GitHub.InlineReviews/Views/CommentView.xaml.cs @@ -1,15 +1,14 @@ using System; -using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Threading; using GitHub.InlineReviews.ViewModels; using GitHub.Services; +using GitHub.UI; using Microsoft.VisualStudio.Shell; using ReactiveUI; namespace GitHub.InlineReviews.Views { - public class GenericCommentView : GitHub.UI.ViewBase { } + public class GenericCommentView : ViewBase { } public partial class CommentView : GenericCommentView { diff --git a/src/GitHub.TeamFoundation.14/Base/EnsureLoggedInSection.cs b/src/GitHub.TeamFoundation.14/Base/EnsureLoggedInSection.cs index 1722840b27..4a8322cca2 100644 --- a/src/GitHub.TeamFoundation.14/Base/EnsureLoggedInSection.cs +++ b/src/GitHub.TeamFoundation.14/Base/EnsureLoggedInSection.cs @@ -6,7 +6,6 @@ using GitHub.Extensions; using GitHub.Primitives; using GitHub.Services; -using GitHub.UI; using GitHub.VisualStudio.Base; using GitHub.VisualStudio.UI; @@ -15,14 +14,17 @@ namespace GitHub.VisualStudio.TeamExplorer.Sync public class EnsureLoggedInSection : TeamExplorerSectionBase { readonly ITeamExplorerServices teServices; + readonly IDialogService dialogService; public EnsureLoggedInSection(IGitHubServiceProvider serviceProvider, ISimpleApiClientFactory apiFactory, ITeamExplorerServiceHolder holder, - IConnectionManager cm, ITeamExplorerServices teServices) + IConnectionManager cm, ITeamExplorerServices teServices, + IDialogService dialogService) : base(serviceProvider, apiFactory, holder, cm) { IsVisible = false; this.teServices = teServices; + this.dialogService = dialogService; } public override void Initialize(IServiceProvider serviceProvider) @@ -55,17 +57,9 @@ async Task CheckLogin() var msg = string.Format(CultureInfo.CurrentUICulture, Resources.NotLoggedInMessage, add.Title, add.Title); teServices.ShowMessage( msg, - new Primitives.RelayCommand(_ => StartFlow(UIControllerFlow.Authentication)) + new Primitives.RelayCommand(_ => dialogService.ShowLoginDialog()) ); } } - - void StartFlow(UIControllerFlow controllerFlow) - { - var uiProvider = ServiceProvider.GetService(); - var controller = uiProvider.Configure(controllerFlow); - controller.TransitionSignal.Subscribe(c => { }, () => CheckLogin().Forget()); - uiProvider.RunInDialog(controller); - } } } \ No newline at end of file diff --git a/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs b/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs index 1f3d3f312d..0ded1f655c 100644 --- a/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs +++ b/src/GitHub.TeamFoundation.14/Connect/GitHubConnectSection.cs @@ -174,7 +174,7 @@ void RefreshConnections(object sender, NotifyCollectionChangedEventArgs e) protected void Refresh(IConnection connection) { - if (connection == null) + if (connection == null || !connection.IsLoggedIn) { LoggedIn = false; IsVisible = false; @@ -381,7 +381,7 @@ async Task RefreshRepositories() public void DoCreate() { - StartFlow(UIControllerFlow.Create); + dialogService.ShowCreateRepositoryDialog(SectionConnection); } public void SignOut() @@ -391,7 +391,7 @@ public void SignOut() public void Login() { - StartFlow(UIControllerFlow.Authentication); + dialogService.ShowLoginDialog(); } public bool OpenRepository() @@ -416,31 +416,6 @@ public bool OpenRepository() return true; } - void StartFlow(UIControllerFlow controllerFlow) - { - var notifications = ServiceProvider.TryGetService(); - var teServices = ServiceProvider.TryGetService(); - notifications.AddListener(teServices); - - ServiceProvider.GitServiceProvider = TEServiceProvider; - var uiProvider = ServiceProvider.TryGetService(); - var controller = uiProvider.Configure(controllerFlow, SectionConnection); - controller.ListenToCompletionState() - .Subscribe(success => - { - if (success) - { - if (controllerFlow == UIControllerFlow.Clone) - isCloning = true; - else if (controllerFlow == UIControllerFlow.Create) - isCreating = true; - } - }); - uiProvider.RunInDialog(controller); - - notifications.RemoveListener(); - } - bool disposed; protected override void Dispose(bool disposing) { diff --git a/src/GitHub.TeamFoundation.14/Connect/GitHubInvitationSection.cs b/src/GitHub.TeamFoundation.14/Connect/GitHubInvitationSection.cs index bcf29170f0..9cd08fedd4 100644 --- a/src/GitHub.TeamFoundation.14/Connect/GitHubInvitationSection.cs +++ b/src/GitHub.TeamFoundation.14/Connect/GitHubInvitationSection.cs @@ -11,6 +11,7 @@ using System.Windows; using System.Windows.Media; using GitHub.VisualStudio.UI; +using System.Linq; namespace GitHub.VisualStudio.TeamExplorer.Connect { @@ -20,12 +21,18 @@ public class GitHubInvitationSection : TeamExplorerInvitationBase { public const string GitHubInvitationSectionId = "C2443FCC-6D62-4D31-B08A-C4DE70109C7F"; public const int GitHubInvitationSectionPriority = 100; + readonly IDialogService dialogService; readonly Lazy lazyBrowser; [ImportingConstructor] - public GitHubInvitationSection(IGitHubServiceProvider serviceProvider, IConnectionManager cm, Lazy browser) + public GitHubInvitationSection( + IGitHubServiceProvider serviceProvider, + IDialogService dialogService, + IConnectionManager cm, + Lazy browser) : base(serviceProvider) { + this.dialogService = dialogService; lazyBrowser = browser; CanConnect = true; CanSignUp = true; @@ -40,15 +47,14 @@ public GitHubInvitationSection(IGitHubServiceProvider serviceProvider, IConnecti OnThemeChanged(); }; - IsVisible = cm.Connections.Count == 0; + IsVisible = !cm.Connections.Where(x => x.IsLoggedIn).Any(); - cm.Connections.CollectionChanged += (s, e) => IsVisible = cm.Connections.Count == 0; + cm.Connections.CollectionChanged += (s, e) => IsVisible = !cm.Connections.Where(x => x.IsLoggedIn).Any(); } public override void Connect() { - StartFlow(UIControllerFlow.Authentication); - base.Connect(); + dialogService.ShowLoginDialog(); } public override void SignUp() @@ -56,12 +62,6 @@ public override void SignUp() OpenInBrowser(lazyBrowser, GitHubUrls.Plans); } - void StartFlow(UIControllerFlow controllerFlow) - { - var uiProvider = ServiceProvider.TryGetService(); - uiProvider.RunInDialog(controllerFlow); - } - void OnThemeChanged() { var theme = Helpers.Colors.DetectTheme(); diff --git a/src/GitHub.TeamFoundation.14/Home/GitHubHomeSection.cs b/src/GitHub.TeamFoundation.14/Home/GitHubHomeSection.cs index b7cded8a9a..2ab3a1f835 100644 --- a/src/GitHub.TeamFoundation.14/Home/GitHubHomeSection.cs +++ b/src/GitHub.TeamFoundation.14/Home/GitHubHomeSection.cs @@ -32,6 +32,7 @@ public class GitHubHomeSection : TeamExplorerSectionBase, IGitHubHomeSection readonly ITeamExplorerServices teamExplorerServices; readonly IPackageSettings settings; readonly IUsageTracker usageTracker; + readonly IDialogService dialogService; [ImportingConstructor] public GitHubHomeSection(IGitHubServiceProvider serviceProvider, @@ -40,7 +41,8 @@ public GitHubHomeSection(IGitHubServiceProvider serviceProvider, IVisualStudioBrowser visualStudioBrowser, ITeamExplorerServices teamExplorerServices, IPackageSettings settings, - IUsageTracker usageTracker) + IUsageTracker usageTracker, + IDialogService dialogService) : base(serviceProvider, apiFactory, holder) { Title = "GitHub"; @@ -50,6 +52,7 @@ public GitHubHomeSection(IGitHubServiceProvider serviceProvider, this.teamExplorerServices = teamExplorerServices; this.settings = settings; this.usageTracker = usageTracker; + this.dialogService = dialogService; var openOnGitHub = ReactiveCommand.Create(); openOnGitHub.Subscribe(_ => DoOpenOnGitHub()); @@ -115,26 +118,7 @@ static Octicon GetIcon(bool isPrivate, bool isHosted, bool isFork) public void Login() { - StartFlow(UIControllerFlow.Authentication); - } - - void StartFlow(UIControllerFlow controllerFlow) - { - var notifications = ServiceProvider.TryGetService(); - var teServices = ServiceProvider.TryGetService(); - notifications.AddListener(teServices); - - ServiceProvider.GitServiceProvider = TEServiceProvider; - var uiProvider = ServiceProvider.TryGetService(); - var controller = uiProvider.Configure(controllerFlow); - controller.ListenToCompletionState() - .Subscribe(success => - { - Refresh(); - }); - uiProvider.RunInDialog(controller); - - notifications.RemoveListener(); + dialogService.ShowLoginDialog(); } void DoOpenOnGitHub() diff --git a/src/GitHub.TeamFoundation.14/Home/PullRequestsNavigationItem.cs b/src/GitHub.TeamFoundation.14/Home/PullRequestsNavigationItem.cs index ef10c21008..fb9903cc7d 100644 --- a/src/GitHub.TeamFoundation.14/Home/PullRequestsNavigationItem.cs +++ b/src/GitHub.TeamFoundation.14/Home/PullRequestsNavigationItem.cs @@ -36,7 +36,7 @@ public PullRequestsNavigationItem(IGitHubServiceProvider serviceProvider, public override void Execute() { var menu = menuProvider.Menus.FirstOrDefault(m => m.IsMenuType(MenuType.OpenPullRequests)); - menu?.Activate(UIControllerFlow.PullRequestList); + menu?.Activate(); base.Execute(); } } diff --git a/src/GitHub.TeamFoundation.14/Sync/EnsureLoggedInSectionSync.cs b/src/GitHub.TeamFoundation.14/Sync/EnsureLoggedInSectionSync.cs index 76be64acf1..7bafa574f5 100644 --- a/src/GitHub.TeamFoundation.14/Sync/EnsureLoggedInSectionSync.cs +++ b/src/GitHub.TeamFoundation.14/Sync/EnsureLoggedInSectionSync.cs @@ -15,8 +15,9 @@ public class EnsureLoggedInSectionSync : EnsureLoggedInSection [ImportingConstructor] public EnsureLoggedInSectionSync(IGitHubServiceProvider serviceProvider, ISimpleApiClientFactory apiFactory, ITeamExplorerServiceHolder holder, - IConnectionManager cm, ITeamExplorerServices teServices) - : base(serviceProvider, apiFactory, holder, cm, teServices) + IConnectionManager cm, ITeamExplorerServices teServices, + IDialogService dialogService) + : base(serviceProvider, apiFactory, holder, cm, teServices, dialogService) {} } } \ No newline at end of file diff --git a/src/GitHub.TeamFoundation.14/Sync/GitHubPublishSection.cs b/src/GitHub.TeamFoundation.14/Sync/GitHubPublishSection.cs index 003821ea05..3ce2d7c286 100644 --- a/src/GitHub.TeamFoundation.14/Sync/GitHubPublishSection.cs +++ b/src/GitHub.TeamFoundation.14/Sync/GitHubPublishSection.cs @@ -1,25 +1,26 @@ using System; using System.ComponentModel.Composition; +using System.Globalization; +using System.Reactive.Linq; +using System.Threading.Tasks; +using System.Windows; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Info; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; using GitHub.UI; +using GitHub.ViewModels; +using GitHub.ViewModels.TeamExplorer; using GitHub.VisualStudio.Base; using GitHub.VisualStudio.Helpers; +using GitHub.VisualStudio.UI; using GitHub.VisualStudio.UI.Views; using Microsoft.TeamFoundation.Controls; -using GitHub.Models; -using GitHub.Services; -using GitHub.Info; -using ReactiveUI; -using System.Reactive.Linq; -using GitHub.Extensions; -using GitHub.Api; -using GitHub.VisualStudio.TeamExplorer; -using System.Windows.Controls; -using GitHub.VisualStudio.UI; -using GitHub.ViewModels; -using System.Globalization; -using GitHub.Primitives; using Microsoft.VisualStudio; -using System.Threading.Tasks; +using ReactiveUI; namespace GitHub.VisualStudio.TeamExplorer.Sync { @@ -30,16 +31,19 @@ public class GitHubPublishSection : TeamExplorerSectionBase, IGitHubInvitationSe public const string GitHubPublishSectionId = "92655B52-360D-4BF5-95C5-D9E9E596AC76"; readonly Lazy lazyBrowser; + readonly IDialogService dialogService; bool loggedIn; [ImportingConstructor] public GitHubPublishSection(IGitHubServiceProvider serviceProvider, ISimpleApiClientFactory apiFactory, ITeamExplorerServiceHolder holder, - IConnectionManager cm, Lazy browser) + IConnectionManager cm, Lazy browser, + IDialogService dialogService) : base(serviceProvider, apiFactory, holder, cm) { lazyBrowser = browser; + this.dialogService = dialogService; Title = Resources.GitHubPublishSectionTitle; Name = "GitHub"; Provider = "GitHub, Inc"; @@ -102,29 +106,30 @@ public void SignUp() public void ShowPublish() { - IsBusy = true; - var uiProvider = ServiceProvider.GetService(); - var controller = uiProvider.Configure(UIControllerFlow.Publish); - bool success = false; - controller.ListenToCompletionState().Subscribe(s => success = s); - - controller.TransitionSignal.Subscribe(data => - { - var vm = (IHasBusy)data.View.ViewModel; - SectionContent = data.View; - vm.WhenAnyValue(x => x.IsBusy).Subscribe(x => IsBusy = x); - }, - () => - { - // there's no real cancel button in the publish form, but if support a back button there, then we want to hide the form - IsVisible = false; - if (success) + var factory = ServiceProvider.GetService(); + var viewModel = ServiceProvider.GetService(); + var busy = viewModel.WhenAnyValue(x => x.IsBusy).Subscribe(x => IsBusy = x); + var completed = viewModel.PublishRepository + .Where(x => x == ProgressState.Success) + .Subscribe(_ => { ServiceProvider.TryGetService()?.NavigateToPage(new Guid(TeamExplorerPageIds.Home), null); HandleCreatedRepo(ActiveRepo); - } - }); - uiProvider.Run(controller); + }); + + var view = factory.CreateView(); + view.DataContext = viewModel; + SectionContent = view; + + Observable.FromEventPattern( + x => view.Unloaded += x, + x => view.Unloaded -= x) + .Take(1) + .Subscribe(_ => + { + busy.Dispose(); + completed.Dispose(); + }); } void HandleCreatedRepo(ILocalRepositoryModel newrepo) @@ -170,9 +175,7 @@ private void ShowNotification(ILocalRepositoryModel newrepo, string msg) async Task Login() { - var uiProvider = ServiceProvider.GetService(); - uiProvider.RunInDialog(UIControllerFlow.Authentication); - + await dialogService.ShowLoginDialog(); loggedIn = await connectionManager.IsLoggedIn(); if (loggedIn) ShowPublish(); diff --git a/src/GitHub.TeamFoundation.15/GitHub.TeamFoundation.15.csproj b/src/GitHub.TeamFoundation.15/GitHub.TeamFoundation.15.csproj index 30167006ac..b6de976bcb 100644 --- a/src/GitHub.TeamFoundation.15/GitHub.TeamFoundation.15.csproj +++ b/src/GitHub.TeamFoundation.15/GitHub.TeamFoundation.15.csproj @@ -167,6 +167,9 @@ + + Services\VSGitExt.cs + Settings.cs diff --git a/src/GitHub.UI.Reactive/Assets/Controls/Validation/UserErrorMessages.xaml b/src/GitHub.UI.Reactive/Assets/Controls/Validation/UserErrorMessages.xaml index 1e60f536bb..233bc23d2f 100644 --- a/src/GitHub.UI.Reactive/Assets/Controls/Validation/UserErrorMessages.xaml +++ b/src/GitHub.UI.Reactive/Assets/Controls/Validation/UserErrorMessages.xaml @@ -18,7 +18,7 @@ - + - - - - - - - + - /// Internal base class for views. Do not use, instead use . - /// - /// - /// It is not possible in WPF XAML to create control templates for generic classes, so this class - /// defines the dependency properties needed by - /// control template. The constructor is internal so this class cannot be inherited directly. - /// - public class ViewBase : ContentControl - { - static readonly DependencyPropertyKey ErrorMessagePropertyKey = - DependencyProperty.RegisterReadOnly( - nameof(ErrorMessage), - typeof(string), - typeof(ViewBase), - new FrameworkPropertyMetadata()); - - static readonly DependencyPropertyKey HasStatePropertyKey = - DependencyProperty.RegisterReadOnly( - nameof(HasState), - typeof(bool), - typeof(ViewBase), - new FrameworkPropertyMetadata()); - - static readonly DependencyPropertyKey IsBusyPropertyKey = - DependencyProperty.RegisterReadOnly( - nameof(IsBusy), - typeof(bool), - typeof(ViewBase), - new FrameworkPropertyMetadata()); - - static readonly DependencyPropertyKey IsLoadingPropertyKey = - DependencyProperty.RegisterReadOnly( - nameof(IsLoading), - typeof(bool), - typeof(ViewBase), - new FrameworkPropertyMetadata()); - - static readonly DependencyPropertyKey ShowContentPropertyKey = - DependencyProperty.RegisterReadOnly( - nameof(ShowContent), - typeof(bool), - typeof(ViewBase), - new FrameworkPropertyMetadata()); - - static readonly DependencyProperty ShowBusyStateProperty = - DependencyProperty.Register( - nameof(ShowBusyState), - typeof(bool), - typeof(ViewBase), - new FrameworkPropertyMetadata(true)); - - public static readonly DependencyProperty ErrorMessageProperty = ErrorMessagePropertyKey.DependencyProperty; - public static readonly DependencyProperty HasStateProperty = HasStatePropertyKey.DependencyProperty; - public static readonly DependencyProperty IsBusyProperty = IsBusyPropertyKey.DependencyProperty; - public static readonly DependencyProperty IsLoadingProperty = IsLoadingPropertyKey.DependencyProperty; - public static readonly DependencyProperty ShowContentProperty = ShowContentPropertyKey.DependencyProperty; - - static ViewBase() - { - DefaultStyleKeyProperty.OverrideMetadata(typeof(ViewBase), new FrameworkPropertyMetadata(typeof(ViewBase))); - FocusableProperty.OverrideMetadata(typeof(ViewBase), new FrameworkPropertyMetadata(false)); - KeyboardNavigation.IsTabStopProperty.OverrideMetadata(typeof(ViewBase), new FrameworkPropertyMetadata(false)); - HorizontalContentAlignmentProperty.OverrideMetadata(typeof(ViewBase), new FrameworkPropertyMetadata(HorizontalAlignment.Stretch)); - VerticalContentAlignmentProperty.OverrideMetadata(typeof(ViewBase), new FrameworkPropertyMetadata(VerticalAlignment.Stretch)); - } - - /// - /// Gets a value reflecting the associated view model's property. - /// - public string ErrorMessage - { - get { return (string)GetValue(ErrorMessageProperty); } - protected set { SetValue(ErrorMessagePropertyKey, value); } - } - - /// - /// Gets a value indicating whether the associated view model implements - /// or . - /// - public bool HasState - { - get { return (bool)GetValue(HasStateProperty); } - protected set { SetValue(HasStatePropertyKey, value); } - } - - /// - /// Gets a value reflecting the associated view model's property. - /// - public bool IsBusy - { - get { return (bool)GetValue(IsBusyProperty); } - protected set { SetValue(IsBusyPropertyKey, value); } - } - - /// - /// Gets a value reflecting the associated view model's property. - /// - public bool IsLoading - { - get { return (bool)GetValue(IsLoadingProperty); } - protected set { SetValue(IsLoadingPropertyKey, value); } - } - - /// - /// Gets or sets a value indicating whether to display the view model's busy state. - /// - public bool ShowBusyState - { - get { return (bool)GetValue(ShowBusyStateProperty); } - set { SetValue(ShowBusyStateProperty, value); } - } - - /// - /// Gets or sets a value indicating whether to the view model content. - /// - public bool ShowContent - { - get { return (bool)GetValue(ShowContentProperty); } - protected set { SetValue(ShowContentPropertyKey, value); } - } - - internal ViewBase() - { - } - } - /// /// Base class for views. /// - /// - /// Exposes a typed property and optionally displays - /// and state if the view model implements - /// those interfaces. In addition, if the view model is an , invokes - /// when the escape key is pressed. - /// - public class ViewBase : ViewBase, IView, IViewFor, IDisposable + public class ViewBase : UserControl, IViewFor where TInterface : class, IViewModel where TImplementor : class { public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register( "ViewModel", typeof(TInterface), typeof(TImplementor), new PropertyMetadata(null)); - IDisposable subscriptions; - /// /// Initializes a new instance of the class. /// public ViewBase() { - DataContextChanged += (s, e) => - { - subscriptions?.Dispose(); - ViewModel = (TInterface)e.NewValue; - - var hasLoading = ViewModel as IHasLoading; - var hasBusy = ViewModel as IHasBusy; - var hasErrorState = ViewModel as IHasErrorState; - var subs = new CompositeDisposable(); - - if (hasLoading != null) - { - subs.Add(this.OneWayBind(hasLoading, x => x.IsLoading, x => x.IsLoading)); - } - - if (hasBusy != null) - { - subs.Add(this.OneWayBind(hasBusy, x => x.IsBusy, x => x.IsBusy)); - } - - if (hasErrorState != null) - { - subs.Add(this.OneWayBind(hasErrorState, x => x.ErrorMessage, x => x.ErrorMessage)); - } - - HasState = hasLoading != null || hasBusy != null; - subscriptions = subs; - }; - - this.WhenActivated(d => - { - d(this.Events() - .KeyUp - .Where(x => x.Key == Key.Escape && !x.Handled) - .Subscribe(key => - { - key.Handled = true; - (this.ViewModel as IDialogViewModel)?.Cancel.Execute(null); - })); - }); - - this.WhenAnyValue( - x => x.IsLoading, - x => x.ErrorMessage, - (l, m) => !l && m == null) - .Subscribe(x => ShowContent = x); + DataContextChanged += (s, e) => ViewModel = (TInterface)e.NewValue; + this.WhenAnyValue(x => x.ViewModel).Skip(1).Subscribe(x => DataContext = x); } /// @@ -237,49 +55,6 @@ object IViewFor.ViewModel set { ViewModel = (TInterface)value; } } - /// - /// Gets or sets the control's data context. Required for interaction with ReactiveUI. - /// - IViewModel IView.ViewModel - { - get { return ViewModel; } - } - - bool disposed; - /// - /// Releases the managed or unmanaged resources held by the control. - /// - protected virtual void Dispose(bool disposing) - { - if (!disposed) - { - if (disposing) - { - subscriptions?.Dispose(); - subscriptions = null; - } - - disposed = true; - } - } - - /// - /// The control finalizer. - /// - ~ViewBase() - { - Dispose(false); - } - - /// - /// Releases the managed resources held by the control. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - /// /// Add an automation peer to views and custom controls /// They do not have automation peers or properties by default diff --git a/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj b/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj index e0e6ced7d7..044c05fc1f 100644 --- a/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj +++ b/src/GitHub.UI.Reactive/GitHub.UI.Reactive.csproj @@ -141,10 +141,6 @@ MSBuild:Compile - - MSBuild:Compile - Designer - diff --git a/src/GitHub.UI.Reactive/Themes/Generic.xaml b/src/GitHub.UI.Reactive/Themes/Generic.xaml deleted file mode 100644 index f71c77a01d..0000000000 --- a/src/GitHub.UI.Reactive/Themes/Generic.xaml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/GitHub.UI/Converters/EqualsToVisibilityConverter.cs b/src/GitHub.UI/Converters/EqualsToVisibilityConverter.cs new file mode 100644 index 0000000000..e26403b0da --- /dev/null +++ b/src/GitHub.UI/Converters/EqualsToVisibilityConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Markup; + +namespace GitHub.UI +{ + public class EqualsToVisibilityConverter : MarkupExtension, IValueConverter + { + readonly string visibleValue; + + public EqualsToVisibilityConverter(string visibleValue) + { + this.visibleValue = visibleValue; + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value?.ToString() == visibleValue ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + public override object ProvideValue(IServiceProvider serviceProvider) => this; + } +} diff --git a/src/GitHub.UI/GitHub.UI.csproj b/src/GitHub.UI/GitHub.UI.csproj index b4679ca247..20e9428cf4 100644 --- a/src/GitHub.UI/GitHub.UI.csproj +++ b/src/GitHub.UI/GitHub.UI.csproj @@ -89,6 +89,7 @@ Spinner.xaml + diff --git a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj index a9fe7ebae7..752c100a59 100644 --- a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj +++ b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj @@ -323,7 +323,7 @@ - + @@ -345,55 +345,56 @@ - - GistCreationControl.xaml - OptionsControl.xaml - - StartPageCloneView.xaml + + + GistCreationView.xaml - - RepositoryCloneControl.xaml + + GitHubDialogWindow.xaml - - RepositoryCreationControl.xaml + + LoginCredentialsView.xaml - - LoginControl.xaml + + Login2FaView.xaml - - RepositoryPublishControl.xaml + + RepositoryCloneView.xaml - - TwoFactorControl.xaml + + RepositoryCreationView.xaml - - GitHubPaneView.xaml + + RepositoryRecloneView.xaml - - - NotAGitRepositoryView.xaml + + LoggedOutView.xaml - - NotAGitHubRepositoryView.xaml + + GitHubPaneView.xaml - - LoggedOutView.xaml + + PullRequestListView.xaml + + + PullRequestDetailView.xaml - + PullRequestCreationView.xaml - - PullRequestDetailView.xaml + + NotAGitHubRepositoryView.xaml - - PullRequestListView.xaml + + NotAGitRepositoryView.xaml - - WindowController.xaml + + RepositoryPublishView.xaml + @@ -486,74 +487,74 @@ Designer MSBuild:Compile - + Designer MSBuild:Compile + GitHub.VisualStudio.UI - + MSBuild:Compile Designer - - Designer + MSBuild:Compile - - Designer - MSBuild:Compile - GitHub.VisualStudio.UI - - Designer + MSBuild:Compile + Designer - + MSBuild:Compile Designer - + + MSBuild:Compile Designer + + MSBuild:Compile + Designer - + MSBuild:Compile Designer - + MSBuild:Compile - - + Designer - + Designer MSBuild:Compile - + MSBuild:Compile Designer - + MSBuild:Compile Designer - - Designer + MSBuild:Compile - - Designer - MSBuild:Compile - - Designer + MSBuild:Compile + Designer - + + MSBuild:Compile Designer + + MSBuild:Compile + Designer - + MSBuild:Compile + Designer diff --git a/src/GitHub.VisualStudio/GitHubPackage.cs b/src/GitHub.VisualStudio/GitHubPackage.cs index 18253db03c..029d5bcec8 100644 --- a/src/GitHub.VisualStudio/GitHubPackage.cs +++ b/src/GitHub.VisualStudio/GitHubPackage.cs @@ -4,13 +4,15 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using System.Windows; using GitHub.Api; using GitHub.Extensions; using GitHub.Helpers; +using GitHub.Info; using GitHub.Logging; using GitHub.Models; using GitHub.Services; -using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; using GitHub.VisualStudio.Menus; using GitHub.VisualStudio.UI; using Microsoft.VisualStudio; @@ -19,8 +21,6 @@ using Octokit; using Serilog; using Task = System.Threading.Tasks.Task; -using EnvDTE; -using GitHub.Info; namespace GitHub.VisualStudio { @@ -111,7 +111,6 @@ public GHClient(IProgram program) [ProvideService(typeof(IMenuProvider), IsAsyncQueryable = true)] [ProvideService(typeof(IGitHubServiceProvider), IsAsyncQueryable = true)] [ProvideService(typeof(IUsageTracker), IsAsyncQueryable = true)] - [ProvideService(typeof(IUIProvider), IsAsyncQueryable = true)] [ProvideService(typeof(IGitHubToolWindowManager))] [Guid(ServiceProviderPackageId)] public sealed class ServiceProviderPackage : AsyncPackage, IServiceProviderPackage, IGitHubToolWindowManager @@ -152,12 +151,11 @@ protected override Task InitializeAsync(CancellationToken cancellationToken, IPr AddService(typeof(IUsageTracker), CreateService, true); AddService(typeof(ILoginManager), CreateService, true); AddService(typeof(IMenuProvider), CreateService, true); - AddService(typeof(IUIProvider), CreateService, true); AddService(typeof(IGitHubToolWindowManager), CreateService, true); return Task.CompletedTask; } - public IViewHost ShowHomePane() + public IGitHubPaneViewModel ShowHomePane() { var pane = ShowToolWindow(new Guid(GitHubPane.GitHubPaneGuid)); if (pane == null) @@ -167,7 +165,7 @@ public IViewHost ShowHomePane() { ErrorHandler.Failed(frame.Show()); } - return pane as IViewHost; + return (IGitHubPaneViewModel)((FrameworkElement)pane.Content).DataContext; } static ToolWindowPane ShowToolWindow(Guid windowGuid) @@ -235,11 +233,6 @@ async Task CreateService(IAsyncServiceContainer container, CancellationT var usageService = serviceProvider.GetService(); return new UsageTracker(serviceProvider, usageService); } - else if (serviceType == typeof(IUIProvider)) - { - var sp = await GetServiceAsync(typeof(IGitHubServiceProvider)) as IGitHubServiceProvider; - return new UIProvider(sp); - } else if (serviceType == typeof(IGitHubToolWindowManager)) { return this; diff --git a/src/GitHub.VisualStudio/IServiceProviderPackage.cs b/src/GitHub.VisualStudio/IServiceProviderPackage.cs index 0b048d81ed..60b0da8b22 100644 --- a/src/GitHub.VisualStudio/IServiceProviderPackage.cs +++ b/src/GitHub.VisualStudio/IServiceProviderPackage.cs @@ -1,6 +1,6 @@ -using GitHub.ViewModels; -using System; +using System; using System.Runtime.InteropServices; +using GitHub.ViewModels.GitHubPane; namespace GitHub.VisualStudio { @@ -12,6 +12,6 @@ public interface IServiceProviderPackage : IServiceProvider, Microsoft.VisualStu [ComVisible(true)] public interface IGitHubToolWindowManager { - IViewHost ShowHomePane(); + IGitHubPaneViewModel ShowHomePane(); } } diff --git a/src/GitHub.VisualStudio/Menus/AddConnection.cs b/src/GitHub.VisualStudio/Menus/AddConnection.cs index a112123f29..747141db89 100644 --- a/src/GitHub.VisualStudio/Menus/AddConnection.cs +++ b/src/GitHub.VisualStudio/Menus/AddConnection.cs @@ -1,5 +1,4 @@ using GitHub.Services; -using GitHub.UI; using System; namespace GitHub.VisualStudio.Menus @@ -16,7 +15,7 @@ public AddConnection(IGitHubServiceProvider serviceProvider) public void Activate(object data = null) { - StartFlow(UIControllerFlow.Authentication); + DialogService?.ShowLoginDialog(); } } } diff --git a/src/GitHub.VisualStudio/Menus/CreateGist.cs b/src/GitHub.VisualStudio/Menus/CreateGist.cs index d04f24022c..ce3e6372e3 100644 --- a/src/GitHub.VisualStudio/Menus/CreateGist.cs +++ b/src/GitHub.VisualStudio/Menus/CreateGist.cs @@ -31,7 +31,7 @@ public bool CanShow() public void Activate(object data) { - StartFlow(UIControllerFlow.Gist); + DialogService?.ShowCreateGist(); } } } diff --git a/src/GitHub.VisualStudio/Menus/MenuBase.cs b/src/GitHub.VisualStudio/Menus/MenuBase.cs index 68292f8bd6..91ed4ca118 100644 --- a/src/GitHub.VisualStudio/Menus/MenuBase.cs +++ b/src/GitHub.VisualStudio/Menus/MenuBase.cs @@ -18,6 +18,7 @@ public abstract class MenuBase static readonly ILogger log = LogManager.ForContext(); readonly IGitHubServiceProvider serviceProvider; readonly Lazy apiFactory; + readonly Lazy dialogService; protected IGitHubServiceProvider ServiceProvider { get { return serviceProvider; } } @@ -37,9 +38,10 @@ protected ISimpleApiClient SimpleApiClient } protected ISimpleApiClientFactory ApiFactory => apiFactory.Value; + protected IDialogService DialogService => dialogService.Value; protected MenuBase() - { } + {} protected MenuBase(IGitHubServiceProvider serviceProvider) { @@ -47,6 +49,7 @@ protected MenuBase(IGitHubServiceProvider serviceProvider) this.serviceProvider = serviceProvider; apiFactory = new Lazy(() => ServiceProvider.TryGetService()); + dialogService = new Lazy(() => ServiceProvider.TryGetService()); } protected ILocalRepositoryModel GetRepositoryByPath(string path) @@ -55,10 +58,8 @@ protected ILocalRepositoryModel GetRepositoryByPath(string path) { if (!string.IsNullOrEmpty(path)) { - using (var repo = ServiceProvider.TryGetService().GetRepository(path)) - { - return new LocalRepositoryModel(repo.Info.WorkingDirectory.TrimEnd('\\')); - } + var repo = ServiceProvider.TryGetService().GetRepository(path); + return new LocalRepositoryModel(repo.Info.WorkingDirectory.TrimEnd('\\')); } } catch (Exception ex) @@ -88,18 +89,6 @@ protected ILocalRepositoryModel GetActiveRepo() return activeRepo; } - protected void StartFlow(UIControllerFlow controllerFlow) - { - IConnection connection = null; - if (controllerFlow != UIControllerFlow.Authentication) - { - var activeRepo = GetActiveRepo(); - connection = ServiceProvider.TryGetService()?.Connections - .FirstOrDefault(c => activeRepo?.CloneUrl?.RepositoryName != null && c.HostAddress.Equals(HostAddress.Create(activeRepo.CloneUrl))); - } - ServiceProvider.TryGetService().RunInDialog(controllerFlow, connection); - } - void RefreshRepo() { ActiveRepo = ServiceProvider.TryGetService().ActiveRepo; diff --git a/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs b/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs index 901c54d22c..f37f29f551 100644 --- a/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs +++ b/src/GitHub.VisualStudio/Menus/OpenPullRequests.cs @@ -22,7 +22,7 @@ public OpenPullRequests(IGitHubServiceProvider serviceProvider) public void Activate(object data = null) { var host = ServiceProvider.TryGetService().ShowHomePane(); - host?.ShowView(new ViewWithData(UIControllerFlow.PullRequestList)); + host.ShowPullRequests().Forget(); } } } diff --git a/src/GitHub.VisualStudio/Menus/ShowCurrentPullRequest.cs b/src/GitHub.VisualStudio/Menus/ShowCurrentPullRequest.cs index 058fc75c47..267530efd5 100644 --- a/src/GitHub.VisualStudio/Menus/ShowCurrentPullRequest.cs +++ b/src/GitHub.VisualStudio/Menus/ShowCurrentPullRequest.cs @@ -29,12 +29,9 @@ public void Activate(object data = null) } var pullRequest = session.PullRequest; - var arg = new PullRequestDetailArgument { RepositoryOwner = session.RepositoryOwner, Number = pullRequest.Number }; - var viewWithData = new ViewWithData(UIControllerFlow.PullRequestDetail) { Data = arg }; - var manager = ServiceProvider.TryGetService(); var host = manager.ShowHomePane(); - host?.ShowView(viewWithData); + host.ShowPullRequest(session.RepositoryOwner, host.LocalRepository.Name, pullRequest.Number); } } } diff --git a/src/GitHub.VisualStudio/Services/ShowDialogService.cs b/src/GitHub.VisualStudio/Services/ShowDialogService.cs new file mode 100644 index 0000000000..ec7da398f4 --- /dev/null +++ b/src/GitHub.VisualStudio/Services/ShowDialogService.cs @@ -0,0 +1,62 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Services; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; +using GitHub.VisualStudio.Views.Dialog; + +namespace GitHub.VisualStudio.UI.Services +{ + [Export(typeof(IShowDialogService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class ShowDialogService : IShowDialogService + { + readonly IGitHubServiceProvider serviceProvider; + + [ImportingConstructor] + public ShowDialogService(IGitHubServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public Task Show(IDialogContentViewModel viewModel) + { + var result = default(object); + + using (var dialogViewModel = CreateViewModel()) + using (dialogViewModel.Done.Take(1).Subscribe(x => result = x)) + { + dialogViewModel.Start(viewModel); + + var window = new GitHubDialogWindow(dialogViewModel); + window.ShowModal(); + } + + return Task.FromResult(result); + } + + public async Task ShowWithFirstConnection(TViewModel viewModel) + where TViewModel : IDialogContentViewModel, IConnectionInitializedViewModel + { + var result = default(object); + + using (var dialogViewModel = CreateViewModel()) + using (dialogViewModel.Done.Take(1).Subscribe(x => result = x)) + { + await dialogViewModel.StartWithConnection(viewModel); + + var window = new GitHubDialogWindow(dialogViewModel); + window.ShowModal(); + } + + return result; + } + + IGitHubDialogWindowViewModel CreateViewModel() + { + return serviceProvider.GetService(); + } + } +} diff --git a/src/GitHub.VisualStudio/Services/UIProvider.cs b/src/GitHub.VisualStudio/Services/UIProvider.cs deleted file mode 100644 index 36d52a4c3c..0000000000 --- a/src/GitHub.VisualStudio/Services/UIProvider.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.IO; -using System.Linq; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading.Tasks; -using System.Windows; -using GitHub.Extensions; -using GitHub.Helpers; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using ReactiveUI; -using GitHub.App.Factories; -using GitHub.Exports; -using GitHub.Controllers; -using GitHub.Logging; -using Serilog; - -namespace GitHub.VisualStudio.UI -{ - [Export(typeof(IUIProvider))] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class UIProviderDispatcher : IUIProvider - { - readonly IUIProvider theRealProvider; - - [ImportingConstructor] - public UIProviderDispatcher([Import(typeof(Microsoft.VisualStudio.Shell.SVsServiceProvider))] IServiceProvider serviceProvider) - { - theRealProvider = serviceProvider.GetServiceSafe(); - } - - public IUIController Configure(UIControllerFlow flow, IConnection connection = null, ViewWithData data = null) => theRealProvider.Configure(flow, connection, data); - - public IView GetView(UIViewType which, ViewWithData data = null) => theRealProvider.GetView(which, data); - - public void Run(IUIController controller) => theRealProvider.Run(controller); - - public IUIController Run(UIControllerFlow flow) => theRealProvider.Run(flow); - - public void RunInDialog(IUIController controller) => theRealProvider.RunInDialog(controller); - - public void RunInDialog(UIControllerFlow flow, IConnection connection = null) => theRealProvider.RunInDialog(flow, connection); - - public void StopUI(IUIController controller) => theRealProvider.StopUI(controller); - } - - public class UIProvider : IUIProvider, IDisposable - { - static readonly ILogger log = LogManager.ForContext(); - - WindowController windowController; - - readonly CompositeDisposable disposables = new CompositeDisposable(); - - readonly IGitHubServiceProvider serviceProvider; - - public UIProvider(IGitHubServiceProvider serviceProvider) - { - this.serviceProvider = serviceProvider; - } - - public IView GetView(UIViewType which, ViewWithData data = null) - { - var uiFactory = serviceProvider.GetService(); - var pair = uiFactory.CreateViewAndViewModel(which); - pair.ViewModel.Initialize(data); - pair.View.DataContext = pair.ViewModel; - return pair.View; - } - - public IUIController Configure(UIControllerFlow flow, IConnection connection = null, ViewWithData data = null) - { - var controller = new UIController(serviceProvider); - disposables.Add(controller); - var listener = controller.Configure(flow, connection, data).Publish().RefCount(); - - listener.Subscribe(_ => { }, () => - { - StopUI(controller); - }); - - // if the flow is authentication, we need to show the login dialog. and we can't - // block the main thread on the subscriber, it'll block other handlers, so we're doing - // this on a separate thread and posting the dialog to the main thread - listener - .Where(c => c.Flow == UIControllerFlow.Authentication) - .ObserveOn(RxApp.TaskpoolScheduler) - .Subscribe(c => - { - // nothing to do, we already have a dialog - if (windowController != null) - return; - RunModalDialogForAuthentication(c.Flow, listener, c).Forget(); - }); - - return controller; - } - - public IUIController Run(UIControllerFlow flow) - { - var controller = Configure(flow); - controller.Start(); - return controller; - } - - public void Run(IUIController controller) - { - controller.Start(); - } - - public void RunInDialog(UIControllerFlow flow, IConnection connection = null) - { - var controller = Configure(flow, connection); - RunInDialog(controller); - } - - public void RunInDialog(IUIController controller) - { - var listener = controller.TransitionSignal; - - windowController = new UI.WindowController(listener); - windowController.WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner; - EventHandler stopUIAction = (s, e) => - { - StopUI(controller); - }; - windowController.Closed += stopUIAction; - listener.Subscribe(_ => { }, () => - { - windowController.Closed -= stopUIAction; - windowController.Close(); - StopUI(controller); - }); - - controller.Start(); - windowController.ShowModal(); - windowController = null; - } - - public void StopUI(IUIController controller) - { - try - { - if (!controller.IsStopped) - controller.Stop(); - disposables.Remove(controller); - } - catch (Exception ex) - { - log.Error(ex, "Failed to dispose UI"); - } - } - - async Task RunModalDialogForAuthentication(UIControllerFlow flow, IObservable listener, LoadData initiaLoadData) - { - await ThreadingHelper.SwitchToMainThreadAsync(); - windowController = new WindowController(listener, - (v, f) => f == flow, - (v, f) => f != flow); - windowController.WindowStartupLocation = WindowStartupLocation.CenterOwner; - windowController.Load(initiaLoadData.View); - windowController.ShowModal(); - windowController = null; - } - - bool disposed; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (disposed) return; - - if (disposables != null) - disposables.Dispose(); - disposed = true; - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.VisualStudio/UI/GitHubPane.cs b/src/GitHub.VisualStudio/UI/GitHubPane.cs index 8c28c27e1a..df89bc39b4 100644 --- a/src/GitHub.VisualStudio/UI/GitHubPane.cs +++ b/src/GitHub.VisualStudio/UI/GitHubPane.cs @@ -3,18 +3,19 @@ using System.Diagnostics.CodeAnalysis; using System.Reactive.Linq; using System.Runtime.InteropServices; +using System.Windows; using GitHub.Extensions; -using GitHub.Logging; +using GitHub.Factories; +using GitHub.Models; using GitHub.Services; -using GitHub.UI; using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using ReactiveUI; namespace GitHub.VisualStudio.UI { - /// /// This class implements the tool window exposed by this package and hosts a user control. /// @@ -27,15 +28,16 @@ namespace GitHub.VisualStudio.UI /// /// [Guid(GitHubPaneGuid)] - public class GitHubPane : ToolWindowPane, IServiceProviderAware, IViewHost + public class GitHubPane : ToolWindowPane, IServiceProviderAware { public const string GitHubPaneGuid = "6b0fdc0a-f28e-47a0-8eed-cc296beff6d2"; bool initialized = false; IDisposable viewSubscription; + IGitHubPaneViewModel viewModel; - IView View + FrameworkElement View { - get { return Content as IView; } + get { return Content as FrameworkElement; } set { viewSubscription?.Dispose(); @@ -43,13 +45,14 @@ IView View Content = value; - viewSubscription = value.WhenAnyValue(x => x.ViewModel) + viewSubscription = value.WhenAnyValue(x => x.DataContext) .SelectMany(x => { var pane = x as IGitHubPaneViewModel; return pane?.WhenAnyValue(p => p.IsSearchEnabled, p => p.SearchQuery) ?? Observable.Return(Tuple.Create(false, null)); }) + .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(x => UpdateSearchHost(x.Item1, x.Item2)); } } @@ -66,9 +69,6 @@ public GitHubPane() : base(null) }; ToolBar = new CommandID(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.idGitHubToolbar); ToolBarLocation = (int)VSTWT_LOCATION.VSTWT_TOP; - var provider = Services.GitHubServiceProvider; - var uiProvider = provider.GetServiceSafe(); - View = uiProvider.GetView(Exports.UIViewType.GitHubPane); } public override bool SearchEnabled => true; @@ -83,23 +83,23 @@ public void Initialize(IServiceProvider serviceProvider) { if (!initialized) { - initialized = true; + var provider = VisualStudio.Services.GitHubServiceProvider; + var teServiceHolder = provider.GetService(); + teServiceHolder.ServiceProvider = serviceProvider; - var vm = View.ViewModel as IServiceProviderAware; - Log.Assert(vm != null, "vm != null"); - vm?.Initialize(serviceProvider); - } - } + var factory = provider.GetService(); + viewModel = provider.ExportProvider.GetExportedValue(); + viewModel.InitializeAsync(this).Forget(); - public void ShowView(ViewWithData data) - { - View.ViewModel?.Initialize(data); + View = factory.CreateView(); + View.DataContext = viewModel; + } } [SuppressMessage("Microsoft.Design", "CA1061:DoNotHideBaseClassMethods", Justification = "WTF CA, I'm overriding!")] public override IVsSearchTask CreateSearch(uint dwCookie, IVsSearchQuery pSearchQuery, IVsSearchCallback pSearchCallback) { - var pane = View.ViewModel as IGitHubPaneViewModel; + var pane = View?.DataContext as IGitHubPaneViewModel; if (pane != null) { @@ -111,7 +111,7 @@ public override IVsSearchTask CreateSearch(uint dwCookie, IVsSearchQuery pSearch public override void ClearSearch() { - var pane = View.ViewModel as IGitHubPaneViewModel; + var pane = View?.DataContext as IGitHubPaneViewModel; if (pane != null) { @@ -127,7 +127,7 @@ public override void OnToolWindowCreated() (int)__VSFPROPID5.VSFPROPID_SearchPlacement, __VSSEARCHPLACEMENT.SP_STRETCH) ?? 0); - var pane = View.ViewModel as IGitHubPaneViewModel; + var pane = View?.DataContext as IGitHubPaneViewModel; UpdateSearchHost(pane?.IsSearchEnabled ?? false, pane?.SearchQuery); } diff --git a/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs b/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs index 870bfcf40c..bd67a50b04 100644 --- a/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs +++ b/src/GitHub.VisualStudio/UI/Settings/OptionsControl.xaml.cs @@ -27,7 +27,7 @@ public bool EditorComments private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e) { - var browser = Services.DefaultExportProvider.GetExportedValue(); + var browser = VisualStudio.Services.DefaultExportProvider.GetExportedValue(); browser?.OpenUrl(e.Uri); } } diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml.cs b/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml.cs deleted file mode 100644 index aece067a58..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Reactive.Linq; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Input; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using ReactiveUI; -using System.ComponentModel.Composition; -using GitHub.Services; -using System.Linq; - -namespace GitHub.VisualStudio.UI.Views.Controls -{ - public class GenericStartPageCloneView : ViewBase - {} - - /// - /// Interaction logic for CloneRepoControl.xaml - /// - [ExportView(ViewType=UIViewType.StartPageClone)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class StartPageCloneView : GenericStartPageCloneView - { - public StartPageCloneView() - { - InitializeComponent(); - - IsVisibleChanged += (s, e) => - { - if (IsVisible) - this.TryMoveFocus(FocusNavigationDirection.First).Subscribe(); - }; - } - } -} diff --git a/src/GitHub.VisualStudio/UI/Views/GitHubPaneView.xaml b/src/GitHub.VisualStudio/UI/Views/GitHubPaneView.xaml deleted file mode 100644 index ec71879320..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/GitHubPaneView.xaml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/GitHub.VisualStudio/UI/Views/GitHubPaneViewModel.cs b/src/GitHub.VisualStudio/UI/Views/GitHubPaneViewModel.cs deleted file mode 100644 index 8faa68e661..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/GitHubPaneViewModel.cs +++ /dev/null @@ -1,424 +0,0 @@ -using GitHub.Api; -using GitHub.Controllers; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using GitHub.ViewModels; -using GitHub.VisualStudio.Base; -using GitHub.VisualStudio.Helpers; -using ReactiveUI; -using System; -using System.ComponentModel.Composition; -using System.Reactive.Disposables; -using System.Reactive.Linq; -using System.Threading.Tasks; -using System.Windows.Input; -using GitHub.Info; -using GitHub.Helpers; - -namespace GitHub.VisualStudio.UI.Views -{ - [ExportViewModel(ViewType = UIViewType.GitHubPane)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public class GitHubPaneViewModel : TeamExplorerItemBase, IGitHubPaneViewModel - { - const UIControllerFlow DefaultControllerFlow = UIControllerFlow.PullRequestList; - - bool initialized; - readonly CompositeDisposable disposables = new CompositeDisposable(); - - readonly IConnectionManager connectionManager; - readonly IUIProvider uiProvider; - readonly IVisualStudioBrowser browser; - readonly IUsageTracker usageTracker; - NavigationController navController; - IViewModel controlViewModel; - - bool disabled; - Microsoft.VisualStudio.Shell.OleMenuCommand back, forward, refresh; - int latestReloadCallId; - - [ImportingConstructor] - public GitHubPaneViewModel(IGitHubServiceProvider serviceProvider, - ISimpleApiClientFactory apiFactory, ITeamExplorerServiceHolder holder, - IConnectionManager cm, IUIProvider uiProvider, IVisualStudioBrowser vsBrowser, - IUsageTracker usageTracker) - : base(serviceProvider, apiFactory, holder) - { - Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); - Guard.ArgumentNotNull(apiFactory, nameof(apiFactory)); - Guard.ArgumentNotNull(holder, nameof(holder)); - Guard.ArgumentNotNull(cm, nameof(cm)); - Guard.ArgumentNotNull(uiProvider, nameof(uiProvider)); - Guard.ArgumentNotNull(vsBrowser, nameof(vsBrowser)); - Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); - - this.connectionManager = cm; - this.uiProvider = uiProvider; - this.usageTracker = usageTracker; - - CancelCommand = ReactiveCommand.Create(); - Title = "GitHub"; - Message = String.Empty; - browser = vsBrowser; - - this.WhenAnyValue(x => x.Control.DataContext) - .Subscribe(x => - { - var pageViewModel = x as IPanePageViewModel; - var searchable = x as ISearchablePanePageViewModel; - controlViewModel = x as IViewModel; - - Title = pageViewModel?.Title ?? "GitHub"; - IsSearchEnabled = searchable != null; - SearchQuery = searchable?.SearchQuery; - }); - } - - public override void Initialize(IServiceProvider serviceProvider) - { - Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); - - serviceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.pullRequestCommand, - (s, e) => Load(new ViewWithData(UIControllerFlow.PullRequestList)).Forget()); - - back = serviceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.backCommand, - () => !disabled && (navController?.HasBack ?? false), - () => - { - DisableButtons(); - navController.Back(); - }, - true); - - forward = serviceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.forwardCommand, - () => !disabled && (navController?.HasForward ?? false), - () => - { - DisableButtons(); - navController.Forward(); - }, - true); - - refresh = serviceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.refreshCommand, - () => !disabled, - () => - { - DisableButtons(); - Refresh(); - }, - true); - - serviceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.githubCommand, - () => !disabled && (RepositoryOrigin == RepositoryOrigin.DotCom || RepositoryOrigin == RepositoryOrigin.Enterprise), - () => - { - switch (navController?.Current.CurrentFlow) - { - case UIControllerFlow.PullRequestDetail: - var prDetailViewModel = control.DataContext as IPullRequestDetailViewModel; - if (prDetailViewModel != null) - { - browser.OpenUrl(ActiveRepoUri.ToRepositoryUrl().Append("pull/" + prDetailViewModel.Model.Number)); - } - else - { - goto default; - } - break; - - case UIControllerFlow.PullRequestList: - case UIControllerFlow.PullRequestCreation: - browser.OpenUrl(ActiveRepoUri.ToRepositoryUrl().Append("pulls/")); - break; - - case UIControllerFlow.Home: - default: - browser.OpenUrl(ActiveRepoUri.ToRepositoryUrl()); - break; - } - }, - true); - - serviceProvider.AddCommandHandler(Guids.guidGitHubToolbarCmdSet, PkgCmdIDList.helpCommand, - () => true, - () => - { - browser.OpenUrl(new Uri(GitHubUrls.Documentation)); - usageTracker.IncrementCounter(x => x.NumberOfGitHubPaneHelpClicks).Forget(); - }, - true); - - initialized = true; - - base.Initialize(serviceProvider); - - connectionManager.Connections.CollectionChanged += (_, __) => LoadDefault(); - LoadDefault(); - } - - public void Initialize(ViewWithData data = null) - { - if (!initialized) - return; - - Title = "GitHub"; - Load(data).Forget(); - } - - void SetupNavigation() - { - navController = new NavigationController(uiProvider); - navController - .WhenAnyValue(x => x.HasBack, y => y.HasForward, z => z.Current) - .Where(_ => !navController.IsBusy) - .Subscribe(_ => UpdateToolbar()); - - navController - .WhenAnyValue(x => x.IsBusy) - .Subscribe(v => - { - if (v) - DisableButtons(); - else - UpdateToolbar(); - }); - } - - protected override void RepoChanged(bool changed) - { - base.RepoChanged(changed); - - if (!initialized) - return; - - if (changed) - { - Stop(); - RepositoryOrigin = RepositoryOrigin.Unknown; - } - - Refresh(); - } - - void LoadDefault() - { - Load(new ViewWithData(DefaultControllerFlow)).Forget(); - } - - void Refresh() - { - Load(null).Forget(); - } - - /// - /// This method is reentrant, so all await calls need to be done before - /// any actions are performed on the data. More recent calls to this method - /// will cause previous calls pending on await calls to exit early. - /// - /// - async Task Load(ViewWithData data) - { - if (!initialized) - return; - - latestReloadCallId++; - var reloadCallId = latestReloadCallId; - - if (RepositoryOrigin == RepositoryOrigin.Unknown) - { - var origin = await GetRepositoryOrigin(); - if (reloadCallId != latestReloadCallId) - return; - - RepositoryOrigin = origin; - } - - var connection = await connectionManager.LookupConnection(ActiveRepo); - if (reloadCallId != latestReloadCallId) - return; - - if (connection == null) - IsLoggedIn = false; - else - { - if (reloadCallId != latestReloadCallId) - return; - - IsLoggedIn = connection.IsLoggedIn; - } - - Load(connection, data); - } - - void Load(IConnection connection, ViewWithData data) - { - if (RepositoryOrigin == UI.RepositoryOrigin.NonGitRepository) - { - LoadSingleView(UIViewType.NotAGitRepository, data); - } - else if (RepositoryOrigin == UI.RepositoryOrigin.Other) - { - LoadSingleView(UIViewType.NotAGitHubRepository, data); - } - else if (!IsLoggedIn) - { - LoadSingleView(UIViewType.LoggedOut, data); - } - else - { - var flow = DefaultControllerFlow; - if (navController != null) - { - flow = navController.Current.SelectedFlow; - } - else - { - SetupNavigation(); - } - - if (data == null) - data = new ViewWithData(flow); - - navController.LoadView(connection, data, view => - { - Control = view; - UpdateToolbar(); - }); - } - } - - void LoadSingleView(UIViewType type, ViewWithData data) - { - Stop(); - Control = uiProvider.GetView(type, data); - } - - void UpdateToolbar() - { - back.Enabled = navController?.HasBack ?? false; - forward.Enabled = navController?.HasForward ?? false; - refresh.Enabled = navController?.Current != null; - disabled = false; - } - - void DisableButtons() - { - disabled = true; - back.Enabled = false; - forward.Enabled = false; - refresh.Enabled = false; - } - - void Stop() - { - DisableButtons(); - navController = null; - disposables.Clear(); - UpdateToolbar(); - } - - string title; - public string Title - { - get { return title; } - set { title = value; this.RaisePropertyChange(); } - } - - IView control; - public IView Control - { - get { return control; } - set { control = value; this.RaisePropertyChange(); } - } - - bool isLoggedIn; - public bool IsLoggedIn - { - get { return isLoggedIn; } - set { isLoggedIn = value; this.RaisePropertyChange(); } - } - - public RepositoryOrigin RepositoryOrigin { get; private set; } - - string message; - public string Message - { - get { return message; } - set { message = value; this.RaisePropertyChange(); } - } - - MessageType messageType; - public MessageType MessageType - { - get { return messageType; } - set { messageType = value; this.RaisePropertyChange(); } - } - - public bool? IsGitHubRepo - { - get - { - return RepositoryOrigin == RepositoryOrigin.Unknown ? - (bool?)null : - RepositoryOrigin == UI.RepositoryOrigin.DotCom || - RepositoryOrigin == UI.RepositoryOrigin.Enterprise; - } - } - - bool isSearchEnabled; - public bool IsSearchEnabled - { - get { return isSearchEnabled; } - private set { isSearchEnabled = value; this.RaisePropertyChange(); } - } - - string searchQuery; - public string SearchQuery - { - get { return searchQuery; } - set - { - var searchable = controlViewModel as ISearchablePanePageViewModel; - value = searchable != null ? value : null; - - if (searchQuery != value) - { - searchQuery = value; - - ThreadingHelper.MainThreadDispatcher.Invoke(() => - { - this.RaisePropertyChange(); - - if (searchable != null) - { - searchable.SearchQuery = searchQuery; - } - }); - } - } - } - - public ReactiveCommand CancelCommand { get; private set; } - public ICommand Cancel => CancelCommand; - - public bool IsShowing => true; - - bool disposed = false; - protected override void Dispose(bool disposing) - { - if (disposing) - { - if (!disposed) - { - disposed = true; - DisableButtons(); - disposables.Dispose(); - } - } - base.Dispose(disposing); - } - } -} diff --git a/src/GitHub.VisualStudio/UI/Views/PullRequestListView.xaml.cs b/src/GitHub.VisualStudio/UI/Views/PullRequestListView.xaml.cs deleted file mode 100644 index fa7eead8bf..0000000000 --- a/src/GitHub.VisualStudio/UI/Views/PullRequestListView.xaml.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using GitHub.Exports; -using GitHub.Extensions; -using GitHub.UI; -using GitHub.ViewModels; -using ReactiveUI; -using System.ComponentModel.Composition; -using System.Reactive.Subjects; -using System.Windows.Input; -using GitHub.Services; -using GitHub.Primitives; -using System.Diagnostics; -using System.Reactive.Linq; -using System.Windows; -using System.Reactive.Disposables; -using GitHub.Logging; - -namespace GitHub.VisualStudio.UI.Views -{ - public class GenericPullRequestListView : ViewBase - { } - - [ExportView(ViewType = UIViewType.PRList)] - [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class PullRequestListView : GenericPullRequestListView, IDisposable - { - readonly Subject open = new Subject(); - readonly Subject create = new Subject(); - - [ImportingConstructor] - public PullRequestListView() - { - InitializeComponent(); - - this.WhenActivated(d => - { - }); - } - - bool disposed; - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (!disposed) - { - if (disposing) - { - open.Dispose(); - create.Dispose(); - } - - disposed = true; - } - } - } -} diff --git a/src/GitHub.VisualStudio/UI/WindowController.xaml b/src/GitHub.VisualStudio/UI/WindowController.xaml deleted file mode 100644 index a45dfcdf48..0000000000 --- a/src/GitHub.VisualStudio/UI/WindowController.xaml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/GitHub.VisualStudio/UI/WindowController.xaml.cs b/src/GitHub.VisualStudio/UI/WindowController.xaml.cs deleted file mode 100644 index e6915ca840..0000000000 --- a/src/GitHub.VisualStudio/UI/WindowController.xaml.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.ComponentModel; -using System.Windows.Controls; -using GitHub.UI; -using GitHub.ViewModels; -using Microsoft.VisualStudio.PlatformUI; - -namespace GitHub.VisualStudio.UI -{ - public partial class WindowController : DialogWindow, IDisposable - { - IDisposable subscription; - readonly IObservable controls; - readonly Func shouldLoad; - readonly Func shouldStop; - - /// - /// - /// - /// Observable that provides controls to host in this window - /// If set, this condition will be checked before loading each control - /// If set, this condition will be checked to determine when to close this window - public WindowController(IObservable controls, - Func shouldLoad = null, - Func shouldStop = null) - { - this.controls = controls; - this.shouldLoad = shouldLoad; - this.shouldStop = shouldStop; - - InitializeComponent(); - Initialize(); - } - - void Initialize() - { - subscription = controls.Subscribe(c => - { - if (shouldLoad == null || shouldLoad(c.View, c.Flow)) - Load(c.View); - if (shouldStop != null && shouldStop(c.View, c.Flow)) - { - Stop(); - Close(); - } - }); - Closed += (s, e) => Dispose(); - } - - public void Load(IView view) - { - var viewModel = view.ViewModel as IDialogViewModel; - if (viewModel != null) - Title = viewModel.Title; - - var control = view as Control; - if (control != null) - { - Container.Children.Clear(); - Container.Children.Add(control); - } - } - - public void Stop() - { - subscription?.Dispose(); - } - - bool disposed = false; - protected void Dispose(bool disposing) - { - if (disposing) - { - if (!disposed) - { - disposed = true; - Stop(); - } - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/GitHub.VisualStudio/Views/ContentView.cs b/src/GitHub.VisualStudio/Views/ContentView.cs new file mode 100644 index 0000000000..19be427d7c --- /dev/null +++ b/src/GitHub.VisualStudio/Views/ContentView.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel.Composition; +using System.Windows.Controls; +using System.Windows.Data; +using GitHub.Exports; +using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views +{ + /// + /// A view that simply displays whatever is in the Content property of its DataContext. + /// + /// + /// A control which displays the Content property of a view model is commonly needed when + /// displaying multi-page interfaces. To use this control as a view for such a purpose, + /// simply add an `[ExportViewFor]` attribute to this class with the type of the view model + /// interface. + /// + [ExportViewFor(typeof(ILoginViewModel))] + [ExportViewFor(typeof(INavigationViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class ContentView : ContentControl + { + public ContentView() + { + BindingOperations.SetBinding(this, ContentProperty, new Binding("Content")); + } + } +} diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml b/src/GitHub.VisualStudio/Views/Dialog/GistCreationView.xaml similarity index 77% rename from src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml rename to src/GitHub.VisualStudio/Views/Dialog/GistCreationView.xaml index ad1a3ed785..aadda3a462 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml +++ b/src/GitHub.VisualStudio/Views/Dialog/GistCreationView.xaml @@ -1,18 +1,18 @@ - + @@ -109,4 +109,4 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/GistCreationView.xaml.cs similarity index 84% rename from src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml.cs rename to src/GitHub.VisualStudio/Views/Dialog/GistCreationView.xaml.cs index 247b29562f..da973eb39f 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/GistCreationControl.xaml.cs +++ b/src/GitHub.VisualStudio/Views/Dialog/GistCreationView.xaml.cs @@ -7,21 +7,20 @@ using GitHub.Extensions; using GitHub.Services; using GitHub.UI; -using GitHub.ViewModels; -using Microsoft.VisualStudio.Shell; +using GitHub.ViewModels.Dialog; using ReactiveUI; -namespace GitHub.VisualStudio.UI.Views.Controls +namespace GitHub.VisualStudio.Views.Dialog { - public class GenericGistCreationControl : ViewBase + public class GenericGistCreationView : ViewBase { } - [ExportView(ViewType=UIViewType.Gist)] + [ExportViewFor(typeof(IGistCreationViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class GistCreationControl : GenericGistCreationControl + public partial class GistCreationView : GenericGistCreationView { [ImportingConstructor] - public GistCreationControl( + public GistCreationView( INotificationDispatcher notifications, IGitHubServiceProvider serviceProvider) { diff --git a/src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml b/src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml new file mode 100644 index 0000000000..e19ba2f3bf --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml.cs new file mode 100644 index 0000000000..4f09882042 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/GitHubDialogWindow.xaml.cs @@ -0,0 +1,19 @@ +using System; +using GitHub.ViewModels.Dialog; +using Microsoft.VisualStudio.PlatformUI; + +namespace GitHub.VisualStudio.Views.Dialog +{ + /// + /// The main window for GitHub for Visual Studio's dialog. + /// + public partial class GitHubDialogWindow : DialogWindow + { + public GitHubDialogWindow(IGitHubDialogWindowViewModel viewModel) + { + DataContext = viewModel; + viewModel.Done.Subscribe(_ => Close()); + InitializeComponent(); + } + } +} diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/TwoFactorControl.xaml b/src/GitHub.VisualStudio/Views/Dialog/Login2FaView.xaml similarity index 95% rename from src/GitHub.VisualStudio/UI/Views/Controls/TwoFactorControl.xaml rename to src/GitHub.VisualStudio/Views/Dialog/Login2FaView.xaml index 619bb9cfeb..68defa2b64 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/TwoFactorControl.xaml +++ b/src/GitHub.VisualStudio/Views/Dialog/Login2FaView.xaml @@ -1,11 +1,11 @@ - @@ -90,4 +89,4 @@ Content="{x:Static prop:Resources.resendCodeButtonContent}" /> - + diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/TwoFactorControl.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/Login2FaView.xaml.cs similarity index 83% rename from src/GitHub.VisualStudio/UI/Views/Controls/TwoFactorControl.xaml.cs rename to src/GitHub.VisualStudio/Views/Dialog/Login2FaView.xaml.cs index 04458fe1ef..e602a9f453 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/TwoFactorControl.xaml.cs +++ b/src/GitHub.VisualStudio/Views/Dialog/Login2FaView.xaml.cs @@ -1,26 +1,24 @@ using System; +using System.ComponentModel.Composition; using System.Reactive.Linq; -using System.Windows; using GitHub.Exports; -using GitHub.Extensions.Reactive; using GitHub.UI; -using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; using ReactiveUI; -using System.ComponentModel.Composition; -namespace GitHub.VisualStudio.UI.Views.Controls +namespace GitHub.VisualStudio.Views.Dialog { - public class GenericTwoFactorControl : ViewBase + public class GenericLogin2FaView : ViewBase { } /// /// Interaction logic for PasswordView.xaml /// - [ExportView(ViewType=UIViewType.TwoFactor)] + [ExportViewFor(typeof(ILogin2FaViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class TwoFactorControl : GenericTwoFactorControl + public partial class Login2FaView : GenericLogin2FaView { - public TwoFactorControl() + public Login2FaView() { InitializeComponent(); diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml b/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml similarity index 96% rename from src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml rename to src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml index 57a1cf367f..7124f21980 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml +++ b/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml @@ -1,4 +1,4 @@ - + @@ -110,7 +111,7 @@ - + @@ -162,4 +163,4 @@ - + diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml.cs similarity index 92% rename from src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml.cs rename to src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml.cs index 168ee3305d..b29fc4a55b 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/LoginControl.xaml.cs +++ b/src/GitHub.VisualStudio/Views/Dialog/LoginCredentialsView.xaml.cs @@ -1,28 +1,27 @@ using System; using System.ComponentModel.Composition; -using System.Reactive.Disposables; using System.Reactive.Linq; using System.Windows.Input; using GitHub.Controls; using GitHub.Exports; using GitHub.Extensions; using GitHub.UI; -using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; using ReactiveUI; -namespace GitHub.VisualStudio.UI.Views.Controls +namespace GitHub.VisualStudio.Views.Dialog { - public class GenericLoginControl : ViewBase + public class GenericLoginCredentialsView : ViewBase { } /// /// Interaction logic for LoginControl.xaml /// - [ExportView(ViewType=UIViewType.Login)] + [ExportViewFor(typeof(ILoginCredentialsViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class LoginControl : GenericLoginControl + public partial class LoginCredentialsView : GenericLoginCredentialsView { - public LoginControl() + public LoginCredentialsView() { InitializeComponent(); @@ -59,7 +58,7 @@ void SetupDotComBindings(Action d) void SetupEnterpriseBindings(Action d) { d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.IsLoggingIn, x => x.enterpriseloginControlsPanel.IsEnabled, x => x == false)); - + d(this.Bind(ViewModel, vm => vm.EnterpriseLogin.UsernameOrEmail, x => x.enterpriseUserNameOrEmail.Text)); d(this.OneWayBind(ViewModel, vm => vm.EnterpriseLogin.UsernameOrEmailValidator, v => v.enterpriseUserNameOrEmailValidationMessage.ReactiveValidator)); diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCloneControl.xaml b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml similarity index 87% rename from src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCloneControl.xaml rename to src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml index 12b8a2d61e..ccb441dd87 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCloneControl.xaml +++ b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml @@ -1,21 +1,21 @@ - + @@ -155,6 +155,12 @@ IsEnabled="{Binding FilterTextIsEnabled, Mode=OneWay}" AutomationProperties.AutomationId="{x:Static automation:AutomationIDs.SearchRepositoryTextBox}" /> + + @@ -322,4 +328,4 @@ - + diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCloneControl.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml.cs similarity index 82% rename from src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCloneControl.xaml.cs rename to src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml.cs index 35fc595323..6714bfe7b5 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCloneControl.xaml.cs +++ b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCloneView.xaml.cs @@ -2,34 +2,30 @@ using System.Collections; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; +using System.ComponentModel.Composition; +using System.Linq; using System.Reactive.Linq; using System.Windows; -using System.Windows.Controls; using System.Windows.Data; using System.Windows.Input; using GitHub.Exports; using GitHub.Extensions; using GitHub.Models; using GitHub.UI; -using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; using ReactiveUI; -using System.ComponentModel.Composition; -using GitHub.Services; -using System.Linq; -using GitHub.Logging; -namespace GitHub.VisualStudio.UI.Views.Controls +namespace GitHub.VisualStudio.Views.Dialog { - public class GenericRepositoryCloneControl : ViewBase + public class GenericRepositoryCloneView : ViewBase {} /// /// Interaction logic for CloneRepoControl.xaml /// - [ExportView(ViewType=UIViewType.Clone)] + [ExportViewFor(typeof(IRepositoryCloneViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class RepositoryCloneControl : GenericRepositoryCloneControl + public partial class RepositoryCloneView : GenericRepositoryCloneView { readonly Dictionary groups = new Dictionary(); @@ -37,18 +33,18 @@ public partial class RepositoryCloneControl : GenericRepositoryCloneControl DependencyProperty.RegisterReadOnly( nameof(RepositoriesView), typeof(ICollectionView), - typeof(RepositoryCloneControl), + typeof(RepositoryCloneView), new PropertyMetadata(null)); public static readonly DependencyProperty RepositoriesViewProperty = RepositoriesViewPropertyKey.DependencyProperty; - public RepositoryCloneControl() + public RepositoryCloneView() { InitializeComponent(); this.WhenActivated(d => { - d(repositoryList.Events().MouseDoubleClick.InvokeCommand(this, x => x.ViewModel.CloneCommand)); + //d(repositoryList.Events().MouseDoubleClick.InvokeCommand(this, x => x.ViewModel.CloneCommand)); }); IsVisibleChanged += (s, e) => @@ -78,9 +74,9 @@ ListCollectionView CreateRepositoryListCollectionView(IEnumerable @@ -259,4 +259,4 @@ - + diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCreationControl.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCreationView.xaml.cs similarity index 79% rename from src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCreationControl.xaml.cs rename to src/GitHub.VisualStudio/Views/Dialog/RepositoryCreationView.xaml.cs index 31982cbdae..6e1ab5f2d5 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/RepositoryCreationControl.xaml.cs +++ b/src/GitHub.VisualStudio/Views/Dialog/RepositoryCreationView.xaml.cs @@ -1,30 +1,28 @@ using System; +using System.ComponentModel.Composition; using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Windows; using System.Windows.Input; using GitHub.Exports; using GitHub.Extensions; using GitHub.Extensions.Reactive; using GitHub.UI; using GitHub.UserErrors; -using GitHub.ViewModels; +using GitHub.ViewModels.Dialog; using ReactiveUI; -using System.ComponentModel.Composition; -namespace GitHub.VisualStudio.UI.Views.Controls +namespace GitHub.VisualStudio.Views.Dialog { - public class GenericRepositoryCreationControl : ViewBase + public class GenericRepositoryCreationView : ViewBase { } /// - /// Interaction logic for CloneRepoControl.xaml + /// Interaction logic for NewRepositoryCreationView.xaml /// - [ExportView(ViewType=UIViewType.Create)] + [ExportViewFor(typeof(IRepositoryCreationViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class RepositoryCreationControl : GenericRepositoryCreationControl + public partial class RepositoryCreationView : GenericRepositoryCreationView { - public RepositoryCreationControl() + public RepositoryCreationView() { InitializeComponent(); diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml b/src/GitHub.VisualStudio/Views/Dialog/RepositoryRecloneView.xaml similarity index 94% rename from src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml rename to src/GitHub.VisualStudio/Views/Dialog/RepositoryRecloneView.xaml index 4187894d2b..17ae596ab5 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/StartPageCloneView.xaml +++ b/src/GitHub.VisualStudio/Views/Dialog/RepositoryRecloneView.xaml @@ -1,11 +1,11 @@ - - - + + https://github.com/github/VisualStudio - - + + @@ -162,4 +162,4 @@ - + diff --git a/src/GitHub.VisualStudio/Views/Dialog/RepositoryRecloneView.xaml.cs b/src/GitHub.VisualStudio/Views/Dialog/RepositoryRecloneView.xaml.cs new file mode 100644 index 0000000000..4b98387a68 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/Dialog/RepositoryRecloneView.xaml.cs @@ -0,0 +1,33 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Windows.Input; +using GitHub.Exports; +using GitHub.Extensions; +using GitHub.UI; +using GitHub.ViewModels.Dialog; + +namespace GitHub.VisualStudio.Views.Dialog +{ + public class GenericRepositoryRecloneView : ViewBase + {} + + /// + /// Interaction logic for RepositoryRecloneView.xaml + /// + [ExportViewFor(typeof(IRepositoryRecloneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class RepositoryRecloneView : GenericRepositoryRecloneView + { + public RepositoryRecloneView() + { + InitializeComponent(); + + IsVisibleChanged += (s, e) => + { + if (IsVisible) + this.TryMoveFocus(FocusNavigationDirection.First).Subscribe(); + }; + } + } +} diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml new file mode 100644 index 0000000000..5d2bcddbba --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/UI/Views/GitHubPaneView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml.cs similarity index 84% rename from src/GitHub.VisualStudio/UI/Views/GitHubPaneView.xaml.cs rename to src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml.cs index df813ae57a..aa34ad68c4 100644 --- a/src/GitHub.VisualStudio/UI/Views/GitHubPaneView.xaml.cs +++ b/src/GitHub.VisualStudio/Views/GitHubPane/GitHubPaneView.xaml.cs @@ -1,29 +1,30 @@ -using System.ComponentModel.Composition; +using System; +using System.ComponentModel.Composition; +using System.Reactive.Linq; +using System.Windows; +using System.Windows.Threading; using GitHub.Exports; +using GitHub.Services; using GitHub.UI; using GitHub.ViewModels; -using System.Windows; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; -using GitHub.Services; -using System.Windows.Threading; -using System.Reactive.Linq; -using System; -namespace GitHub.VisualStudio.UI.Views +namespace GitHub.VisualStudio.Views.GitHubPane { public class GenericGitHubPaneView : ViewBase { } - [ExportView(ViewType = UIViewType.GitHubPane)] + [ExportViewFor(typeof(IGitHubPaneViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class GitHubPaneView : GenericGitHubPaneView { [ImportingConstructor] public GitHubPaneView(INotificationDispatcher notifications) { - this.InitializeComponent(); + InitializeComponent(); + this.WhenActivated(d => { infoPanel.Visibility = Visibility.Collapsed; @@ -40,4 +41,4 @@ public GitHubPaneView(INotificationDispatcher notifications) }); } } -} \ No newline at end of file +} diff --git a/src/GitHub.VisualStudio/UI/Views/LoggedOutView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/LoggedOutView.xaml similarity index 91% rename from src/GitHub.VisualStudio/UI/Views/LoggedOutView.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/LoggedOutView.xaml index 4fb7a96f12..8ce2f1ba72 100644 --- a/src/GitHub.VisualStudio/UI/Views/LoggedOutView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/LoggedOutView.xaml @@ -1,9 +1,9 @@ - - - - - - - - - diff --git a/src/GitHub.VisualStudio/UI/Views/LoggedOutView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/LoggedOutView.xaml.cs similarity index 78% rename from src/GitHub.VisualStudio/UI/Views/LoggedOutView.xaml.cs rename to src/GitHub.VisualStudio/Views/GitHubPane/LoggedOutView.xaml.cs index 92ba9be08d..c633506849 100644 --- a/src/GitHub.VisualStudio/UI/Views/LoggedOutView.xaml.cs +++ b/src/GitHub.VisualStudio/Views/GitHubPane/LoggedOutView.xaml.cs @@ -1,18 +1,17 @@ using System.ComponentModel.Composition; using GitHub.Exports; using GitHub.UI; -using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; using ReactiveUI; -namespace GitHub.VisualStudio.UI.Views +namespace GitHub.VisualStudio.Views.GitHubPane { public class GenericLoggedOutView : ViewBase { } - [ExportView(ViewType = UIViewType.LoggedOut)] + [ExportViewFor(typeof(ILoggedOutViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class LoggedOutView : GenericLoggedOutView { public LoggedOutView() diff --git a/src/GitHub.VisualStudio/UI/Views/NotAGitHubRepositoryView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/NotAGitHubRepositoryView.xaml similarity index 94% rename from src/GitHub.VisualStudio/UI/Views/NotAGitHubRepositoryView.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/NotAGitHubRepositoryView.xaml index 69f5cce555..47f830a88a 100644 --- a/src/GitHub.VisualStudio/UI/Views/NotAGitHubRepositoryView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/NotAGitHubRepositoryView.xaml @@ -1,9 +1,9 @@ - { } - [ExportView(ViewType = UIViewType.NotAGitHubRepository)] + [ExportViewFor(typeof(INotAGitHubRepositoryViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public partial class NotAGitHubRepositoryView : GenericNotAGitHubRepositoryView { public NotAGitHubRepositoryView() diff --git a/src/GitHub.VisualStudio/UI/Views/NotAGitRepositoryView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/NotAGitRepositoryView.xaml similarity index 94% rename from src/GitHub.VisualStudio/UI/Views/NotAGitRepositoryView.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/NotAGitRepositoryView.xaml index a7c04868a6..1067f357b3 100644 --- a/src/GitHub.VisualStudio/UI/Views/NotAGitRepositoryView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/NotAGitRepositoryView.xaml @@ -1,9 +1,9 @@ - { } - [ExportView(ViewType = UIViewType.NotAGitRepository)] + [ExportViewFor(typeof(INotAGitRepositoryViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public partial class NotAGitRepositoryView : GenericNotAGitRepositoryView diff --git a/src/GitHub.VisualStudio/UI/Views/PullRequestCreationView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestCreationView.xaml similarity index 99% rename from src/GitHub.VisualStudio/UI/Views/PullRequestCreationView.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/PullRequestCreationView.xaml index cedc21faf5..7e74a55ce9 100644 --- a/src/GitHub.VisualStudio/UI/Views/PullRequestCreationView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestCreationView.xaml @@ -1,10 +1,10 @@ - { } - [ExportView(ViewType = UIViewType.PRCreation)] + [ExportViewFor(typeof(IPullRequestCreationViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public partial class PullRequestCreationView : GenericPullRequestCreationView { diff --git a/src/GitHub.VisualStudio/UI/Views/PullRequestDetailView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml similarity index 99% rename from src/GitHub.VisualStudio/UI/Views/PullRequestDetailView.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml index 83e221a129..0aff2db84b 100644 --- a/src/GitHub.VisualStudio/UI/Views/PullRequestDetailView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml @@ -1,15 +1,15 @@ - + xmlns:vsui="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.14.0"> diff --git a/src/GitHub.VisualStudio/UI/Views/PullRequestDetailView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs similarity index 93% rename from src/GitHub.VisualStudio/UI/Views/PullRequestDetailView.xaml.cs rename to src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs index 9b4fea911e..534c9f7155 100644 --- a/src/GitHub.VisualStudio/UI/Views/PullRequestDetailView.xaml.cs +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.Composition; +using System.Globalization; using System.Linq; using System.Reactive.Linq; using System.Windows; @@ -15,6 +16,7 @@ using GitHub.UI; using GitHub.UI.Helpers; using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; using GitHub.VisualStudio.UI.Helpers; using Microsoft.VisualStudio; using Microsoft.VisualStudio.Editor; @@ -22,19 +24,17 @@ using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Projection; +using Microsoft.VisualStudio.TextManager.Interop; using ReactiveUI; using Task = System.Threading.Tasks.Task; -using Microsoft.VisualStudio.TextManager.Interop; -using System.Text; -using System.Globalization; -using Microsoft.VisualStudio.Text.Projection; -namespace GitHub.VisualStudio.UI.Views +namespace GitHub.VisualStudio.Views.GitHubPane { public class GenericPullRequestDetailView : ViewBase { } - [ExportView(ViewType = UIViewType.PRDetail)] + [ExportViewFor(typeof(IPullRequestDetailViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] public partial class PullRequestDetailView : GenericPullRequestDetailView { @@ -97,7 +97,7 @@ async Task DoOpenFile(IPullRequestFileNode file, bool workingDirectory) using (workingDirectory ? null : OpenInProvisionalTab()) { - var window = Services.Dte.ItemOperations.OpenFile(fileName); + var window = GitHub.VisualStudio.Services.Dte.ItemOperations.OpenFile(fileName); window.Document.ReadOnly = !workingDirectory; var buffer = GetBufferAt(fileName); @@ -144,7 +144,7 @@ async Task DoDiffFile(IPullRequestFileNode file, bool workingDirectory) var tooltip = $"{leftLabel}\nvs.\n{rightLabel}"; // Diff window will open in provisional (right hand) tab until document is touched. - frame = Services.DifferenceService.OpenComparisonWindow2( + frame = GitHub.VisualStudio.Services.DifferenceService.OpenComparisonWindow2( leftFile, rightFile, caption, @@ -198,7 +198,7 @@ void AddBufferTag(ITextBuffer buffer, IPullRequestSession session, string path, void ShowErrorInStatusBar(string message, Exception e) { - var ns = Services.DefaultExportProvider.GetExportedValue(); + var ns = GitHub.VisualStudio.Services.DefaultExportProvider.GetExportedValue(); ns?.ShowMessage(message + ": " + e.Message); } @@ -225,13 +225,13 @@ void FileListMouseRightButtonDown(object sender, MouseButtonEventArgs e) ITextBuffer GetBufferAt(string filePath) { - var editorAdapterFactoryService = Services.ComponentModel.GetService(); + var editorAdapterFactoryService = GitHub.VisualStudio.Services.ComponentModel.GetService(); IVsUIHierarchy uiHierarchy; uint itemID; IVsWindowFrame windowFrame; if (VsShellUtilities.IsDocumentOpen( - Services.GitHubServiceProvider, + GitHub.VisualStudio.Services.GitHubServiceProvider, filePath, Guid.Empty, out uiHierarchy, @@ -296,7 +296,7 @@ void BodyFocusHack(object sender, RequestBringIntoViewEventArgs e) void ViewCommentsClick(object sender, RoutedEventArgs e) { var model = (object)ViewModel.Model; - Services.Dte.Commands.Raise( + GitHub.VisualStudio.Services.Dte.Commands.Raise( Guids.CommandSetString, PkgCmdIDList.ShowPullRequestCommentsId, ref model, @@ -322,7 +322,7 @@ async void ViewFileCommentsClick(object sender, RoutedEventArgs e) // to the first changed line. There must be a better way of doing this. await Task.Delay(1500); - Services.Dte.Commands.Raise( + GitHub.VisualStudio.Services.Dte.Commands.Raise( Guids.CommandSetString, PkgCmdIDList.NextInlineCommentId, ref param, diff --git a/src/GitHub.VisualStudio/UI/Views/Controls/PullRequestListItem.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml similarity index 99% rename from src/GitHub.VisualStudio/UI/Views/Controls/PullRequestListItem.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml index df107ca8b0..a9aece170e 100644 --- a/src/GitHub.VisualStudio/UI/Views/Controls/PullRequestListItem.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cache="clr-namespace:GitHub.UI.Helpers;assembly=GitHub.UI" xmlns:i18n="clr-namespace:GitHub.VisualStudio.UI;assembly=GitHub.VisualStudio.UI" - xmlns:local="clr-namespace:GitHub.VisualStudio.UI.Views" + xmlns:local="clr-namespace:GitHub.VisualStudio.Views.GitHubPane" xmlns:models="clr-namespace:GitHub.Models;assembly=GitHub.Exports" xmlns:viewmodels="clr-namespace:GitHub.ViewModels;assembly=GitHub.Exports.Reactive" xmlns:vsui="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.14.0" diff --git a/src/GitHub.VisualStudio/UI/Views/PullRequestListView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListView.xaml similarity index 97% rename from src/GitHub.VisualStudio/UI/Views/PullRequestListView.xaml rename to src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListView.xaml index e3133574b4..1ec7b33c59 100644 --- a/src/GitHub.VisualStudio/UI/Views/PullRequestListView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListView.xaml @@ -1,10 +1,10 @@ - - +