From 599efea6513673dae06bd56739f9eb0893aa365c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 14 Jun 2018 06:35:42 +0200 Subject: [PATCH 01/34] Basic reimplementation of PR list. Using GraphQL and a virtualizing list view. --- .../Collections/SequentialListSource.cs | 136 +++++++ .../Collections/VirtualizingList.cs | 190 +++++++++ .../VirtualizingListCollectionView.cs | 100 +++++ src/GitHub.App/GitHub.App.csproj | 7 + .../SampleData/ActorViewModelDesigner.cs | 26 ++ .../PullRequestListItemViewModelDesigner.cs | 20 + .../PullRequestListViewModelDesigner.cs | 92 ++--- src/GitHub.App/Services/PullRequestService.cs | 72 +++- .../GitHubPane/IssueListViewModelBase.cs | 54 +++ .../PullRequestListItemViewModel.cs | 33 ++ .../GitHubPane/PullRequestListViewModel.cs | 378 ++---------------- .../GitHub.Exports.Reactive.csproj | 2 + .../Services/IPullRequestService.cs | 15 + .../GitHubPane/IIssueListViewModelBase.cs | 19 + .../IPullRequestListItemViewModel.cs | 46 +++ .../GitHubPane/IPullRequestListViewModel.cs | 45 +-- .../Collections/IVirtualizingListSource.cs | 13 + src/GitHub.Exports/GitHub.Exports.csproj | 4 + src/GitHub.Exports/Models/Page.cs | 32 ++ .../Models/PullRequestListItemModel.cs | 45 +++ .../ViewModels/ILabelViewModel.cs | 25 ++ .../GitHub.VisualStudio.csproj | 11 +- .../Views/GitHubPane/PullRequestListItem.xaml | 163 -------- .../GitHubPane/PullRequestListItemView.xaml | 84 ++++ .../PullRequestListItemView.xaml.cs | 28 ++ .../Views/GitHubPane/PullRequestListView.xaml | 205 ++-------- .../GitHubPane/PullRequestListView.xaml.cs | 7 +- 27 files changed, 1032 insertions(+), 820 deletions(-) create mode 100644 src/GitHub.App/Collections/SequentialListSource.cs create mode 100644 src/GitHub.App/Collections/VirtualizingList.cs create mode 100644 src/GitHub.App/Collections/VirtualizingListCollectionView.cs create mode 100644 src/GitHub.App/SampleData/ActorViewModelDesigner.cs create mode 100644 src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs create mode 100644 src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs create mode 100644 src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs create mode 100644 src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs create mode 100644 src/GitHub.Exports/Collections/IVirtualizingListSource.cs create mode 100644 src/GitHub.Exports/Models/Page.cs create mode 100644 src/GitHub.Exports/Models/PullRequestListItemModel.cs create mode 100644 src/GitHub.Exports/ViewModels/ILabelViewModel.cs delete mode 100644 src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml create mode 100644 src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItemView.xaml create mode 100644 src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItemView.xaml.cs diff --git a/src/GitHub.App/Collections/SequentialListSource.cs b/src/GitHub.App/Collections/SequentialListSource.cs new file mode 100644 index 0000000000..81285626bf --- /dev/null +++ b/src/GitHub.App/Collections/SequentialListSource.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; +using GitHub.Logging; +using GitHub.Models; +using Serilog; + +namespace GitHub.Collections +{ + public abstract class SequentialListSource : IVirtualizingListSource + { + static readonly ILogger log = LogManager.ForContext>(); + + readonly Dispatcher dispatcher; + readonly object loadLock = new object(); + Dictionary> pages = new Dictionary>(); + Task loading = Task.CompletedTask; + int? count; + int nextPage; + int loadTo; + string after; + + public SequentialListSource() + { + dispatcher = Application.Current.Dispatcher; + } + + public virtual int PageSize => 100; + event EventHandler PageLoaded; + + public async Task GetCount() + { + dispatcher.VerifyAccess(); + + if (!count.HasValue) + { + count = (await EnsureLoaded(0).ConfigureAwait(false)).TotalCount; + } + + return count.Value; + } + + public async Task> GetPage(int pageNumber) + { + dispatcher.VerifyAccess(); + + var page = await EnsureLoaded(pageNumber); + var result = page.Items + .Select(CreateViewModel) + .ToList(); + pages.Remove(pageNumber); + return result; + } + + protected abstract TViewModel CreateViewModel(TModel model); + protected abstract Task> LoadPage(string after); + + protected virtual void OnBeginLoading() + { + } + + protected virtual void OnEndLoading() + { + } + + async Task> EnsureLoaded(int pageNumber) + { + if (pageNumber < nextPage) + { + return pages[pageNumber]; + } + + var pageLoaded = WaitPageLoaded(pageNumber); + loadTo = Math.Max(loadTo, pageNumber); + + while (true) + { + lock (loadLock) + { + if (loading.IsCompleted) + { + loading = Load(); + } + } + + await Task.WhenAny(loading, pageLoaded).ConfigureAwait(false); + + if (pageLoaded.IsCompleted) + { + return pages[pageNumber]; + } + } + } + + Task WaitPageLoaded(int page) + { + var tcs = new TaskCompletionSource(); + EventHandler handler = null; + handler = (s, e) => + { + if (nextPage > page) + { + tcs.SetResult(true); + PageLoaded -= handler; + } + }; + PageLoaded += handler; + return tcs.Task; + } + + async Task Load() + { + OnBeginLoading(); + + while (nextPage <= loadTo) + { + await LoadNextPage().ConfigureAwait(false); + PageLoaded?.Invoke(this, EventArgs.Empty); + } + + OnEndLoading(); + } + + async Task LoadNextPage() + { + log.Debug("Loading page {Number} of {ModelType}", nextPage, typeof(TModel)); + + var page = await LoadPage(after).ConfigureAwait(false); + pages[nextPage++] = page; + after = page.EndCursor; + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Collections/VirtualizingList.cs b/src/GitHub.App/Collections/VirtualizingList.cs new file mode 100644 index 0000000000..6d24c4972d --- /dev/null +++ b/src/GitHub.App/Collections/VirtualizingList.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Threading; + +namespace GitHub.Collections +{ + public class VirtualizingList : IReadOnlyList, IList, INotifyCollectionChanged + { + readonly Dictionary> pages = new Dictionary>(); + readonly IVirtualizingListSource source; + readonly IList emptyPage; + readonly IReadOnlyList placeholderPage; + readonly Dispatcher dispatcher; + int? count; + + public VirtualizingList( + IVirtualizingListSource source, + T placeholder) + { + this.source = source; + Placeholder = placeholder; + emptyPage = Enumerable.Repeat(default(T), PageSize).ToList(); + placeholderPage = Enumerable.Repeat(placeholder, PageSize).ToList(); + dispatcher = Application.Current.Dispatcher; + } + + public T this[int index] + { + get + { + var pageNumber = index / PageSize; + var pageIndex = index % PageSize; + IReadOnlyList page; + + if (pages.TryGetValue(pageNumber, out page)) + { + return page[pageIndex]; + } + else + { + LoadPage(pageNumber); + return placeholderPage[0]; + } + } + + set { throw new NotImplementedException(); } + } + + public int Count + { + get + { + if (!count.HasValue) + { + count = 0; + LoadCount(); + } + + return count.Value; + } + } + + public T Placeholder { get; } + public IReadOnlyDictionary> Pages => pages; + public int PageSize => source.PageSize; + + object IList.this[int index] + { + get { return this[index]; } + set { this[index] = (T)value; } + } + + bool IList.IsReadOnly => true; + bool IList.IsFixedSize => false; + int ICollection.Count => Count; + object ICollection.SyncRoot => null; + bool ICollection.IsSynchronized => false; + + public event NotifyCollectionChangedEventHandler CollectionChanged; + + public IEnumerator GetEnumerator() + { + var i = 0; + while (i < Count) yield return this[i++]; + } + + int IList.Add(object value) + { + throw new NotImplementedException(); + } + + void IList.Clear() + { + throw new NotImplementedException(); + } + + bool IList.Contains(object value) + { + throw new NotImplementedException(); + } + + int IList.IndexOf(object value) + { + throw new NotImplementedException(); + } + + void IList.Insert(int index, object value) + { + throw new NotImplementedException(); + } + + void IList.Remove(object value) + { + throw new NotImplementedException(); + } + + void IList.RemoveAt(int index) + { + throw new NotImplementedException(); + } + + void ICollection.CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + void LoadCount() + { + dispatcher.VerifyAccess(); + + try + { + var countTask = source.GetCount(); + + if (countTask.IsCompleted) + { + // Don't send a Reset if the count is available immediately, as this causes + // a NullReferenceException in ListCollectionView. + count = countTask.Result; + } + else + { + countTask.ContinueWith(x => + { + count = x.Result; + SendReset(); + }, TaskScheduler.FromCurrentSynchronizationContext()); + } + } + catch (Exception) + { + // Handle exception. + } + } + + async void LoadPage(int number) + { + dispatcher.VerifyAccess(); + + try + { + pages.Add(number, placeholderPage); + var page = await source.GetPage(number); + pages[number] = page; + SendReset(); + } + catch (Exception) + { + // Handle exception. + } + } + + void SendReset() + { + // ListCollectionView (which is used internally by the WPF list controls) doesn't + // support multi-item Replace notifications, so sending a Reset is actually the + // best thing we can do to notify of items being loaded. + CollectionChanged?.Invoke( + this, + new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/Collections/VirtualizingListCollectionView.cs b/src/GitHub.App/Collections/VirtualizingListCollectionView.cs new file mode 100644 index 0000000000..63298cb665 --- /dev/null +++ b/src/GitHub.App/Collections/VirtualizingListCollectionView.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Windows.Data; + +namespace GitHub.Collections +{ + public class VirtualizingListCollectionView : CollectionView, IList + { + List filtered; + + public VirtualizingListCollectionView(VirtualizingList inner) + : base(inner) + { + } + + public override int Count => filtered?.Count ?? Inner.Count; + public override bool IsEmpty => Count == 0; + + bool IList.IsReadOnly => true; + bool IList.IsFixedSize => false; + object ICollection.SyncRoot => null; + bool ICollection.IsSynchronized => false; + + protected VirtualizingList Inner => (VirtualizingList)SourceCollection; + + object IList.this[int index] + { + get { return GetItemAt(index); } + set { throw new NotImplementedException(); } + } + + public override object GetItemAt(int index) + { + if (filtered == null) + { + return Inner[index]; + } + else + { + return Inner[filtered[index]]; + } + } + + int IList.Add(object value) { throw new NotSupportedException(); } + bool IList.Contains(object value) { throw new NotImplementedException(); } + void IList.Clear() { throw new NotSupportedException(); } + int IList.IndexOf(object value) { throw new NotImplementedException(); } + void IList.Insert(int index, object value) { throw new NotSupportedException(); } + void IList.Remove(object value) { throw new NotSupportedException(); } + void IList.RemoveAt(int index) { throw new NotSupportedException(); } + void ICollection.CopyTo(Array array, int index) { throw new NotImplementedException(); } + + protected override void RefreshOverride() + { + if (Filter != null) + { + var result = new List(); + var count = Inner.Count; + var pageCount = (int)Math.Ceiling((double)count / Inner.PageSize); + + for (var i = 0; i < pageCount; ++i) + { + IReadOnlyList page; + + if (Inner.Pages.TryGetValue(i, out page)) + { + var j = 0; + + foreach (var item in page) + { + if (Equals(item, Inner.Placeholder) || Filter(item)) + { + result.Add((i * Inner.PageSize) + j); + } + + ++j; + } + } + else + { + for (var j = 0; j < Inner.PageSize; ++j) + { + result.Add((i * Inner.PageSize) + j); + } + } + } + + filtered = result; + } + else + { + filtered = null; + } + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } +} \ No newline at end of file diff --git a/src/GitHub.App/GitHub.App.csproj b/src/GitHub.App/GitHub.App.csproj index 9ecc382bbc..9583cbed1e 100644 --- a/src/GitHub.App/GitHub.App.csproj +++ b/src/GitHub.App/GitHub.App.csproj @@ -205,16 +205,21 @@ + + + + + @@ -243,10 +248,12 @@ + + diff --git a/src/GitHub.App/SampleData/ActorViewModelDesigner.cs b/src/GitHub.App/SampleData/ActorViewModelDesigner.cs new file mode 100644 index 0000000000..7edc14b0e9 --- /dev/null +++ b/src/GitHub.App/SampleData/ActorViewModelDesigner.cs @@ -0,0 +1,26 @@ +using System; +using System.Windows.Media.Imaging; +using GitHub.Services; +using GitHub.ViewModels; + +namespace GitHub.SampleData +{ + public class ActorViewModelDesigner : ViewModelBase, IActorViewModel + { + public ActorViewModelDesigner() + { + AvatarUrl = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png"; + Avatar = AvatarProvider.CreateBitmapImage(AvatarUrl); + } + + public ActorViewModelDesigner(string login) + : this() + { + Login = login; + } + + public BitmapSource Avatar { get; } + public string AvatarUrl { get; } + public string Login { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs new file mode 100644 index 0000000000..433bd61ec2 --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestListItemViewModelDesigner.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using GitHub.ViewModels; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.SampleData +{ + [ExcludeFromCodeCoverage] + public class PullRequestListItemViewModelDesigner : ViewModelBase, IPullRequestListItemViewModel + { + public string Id { get; set; } + public IActorViewModel Author { get; set; } + public int CommentCount { get; set; } + public IReadOnlyList Labels { get; set; } + public int Number { get; set; } + public string Title { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs index 796e275901..f14413544c 100644 --- a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs @@ -1,14 +1,12 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reactive.Linq; using System.Threading.Tasks; -using GitHub.Collections; +using System.Windows.Data; using GitHub.Models; +using GitHub.ViewModels; using GitHub.ViewModels.GitHubPane; -using ReactiveUI; namespace GitHub.SampleData { @@ -17,69 +15,43 @@ public class PullRequestListViewModelDesigner : PanePageViewModelBase, IPullRequ { public PullRequestListViewModelDesigner() { - var prs = new TrackingCollection(Observable.Empty()); - - prs.Add(new PullRequestModel(399, "Let's try doing this differently", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTimeOffset.Now - TimeSpan.FromDays(1)) - { - Assignee = new AccountDesigner { Login = "shana", IsUser = true }, - }); - prs.Add(new PullRequestModel(389, "Build system upgrade", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTimeOffset.Now - TimeSpan.FromMinutes(2)) + Items = new[] { - CommentCount = 4, - HasNewComments = false, - Assignee = new AccountDesigner { Login = "haacked", IsUser = true }, - }); - prs.Add(new PullRequestModel(409, "Fix publish button style and a really, really long name for this thing... OMG look how long this name is yusssss", - new AccountDesigner { Login = "shana", IsUser = true }, - DateTimeOffset.Now - TimeSpan.FromHours(5)) - { - CommentCount = 27, - HasNewComments = true, - Assignee = new AccountDesigner { Login = "Haacked", IsUser = true }, - }); - PullRequests = prs; - - States = new List { - new PullRequestState { IsOpen = true, Name = "Open" }, - new PullRequestState { IsOpen = false, Name = "Closed" }, - new PullRequestState { Name = "All" } + new PullRequestListItemViewModelDesigner + { + Number = 399, + Title = "Let's try doing this differently", + Author = new ActorViewModelDesigner("shana"), + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromDays(1), + }, + new PullRequestListItemViewModelDesigner + { + Number = 389, + Title = "Build system upgrade", + Author = new ActorViewModelDesigner("haacked"), + CommentCount = 4, + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromMinutes(2), + }, + new PullRequestListItemViewModelDesigner + { + Number = 409, + Title = "Fix publish button style and a really, really long name for this thing... OMG look how long this name is yusssss", + Author = new ActorViewModelDesigner("shana"), + CommentCount = 27, + UpdatedAt = DateTimeOffset.Now - TimeSpan.FromHours(5), + }, }; - SelectedState = States[0]; - Assignees = new ObservableCollection(prs.Select(x => x.Assignee)); - Authors = new ObservableCollection(prs.Select(x => x.Author)); - SelectedAssignee = Assignees.ElementAt(1); - SelectedAuthor = Authors.ElementAt(1); - IsLoaded = true; + ItemsView = CollectionViewSource.GetDefaultView(Items); } - public IReadOnlyList Repositories { get; } - public IRemoteRepositoryModel SelectedRepository { get; set; } - public PullRequestDetailModel CheckedOutPullRequest { get; set; } - public ITrackingCollection PullRequests { get; set; } - public IPullRequestModel SelectedPullRequest { get; set; } + public IReadOnlyList Items { get; } - public IReadOnlyList States { get; set; } - public PullRequestState SelectedState { get; set; } + public ICollectionView ItemsView { get; } - public ObservableCollection Authors { get; set; } - public IAccount SelectedAuthor { get; set; } - public bool RepositoryIsFork { get; set; } = true; - public bool ShowPullRequestsForFork { get; set; } - public string SearchQuery { get; set; } - public bool IsLoaded { get; } - - public ObservableCollection Assignees { get; set; } - public IAccount SelectedAssignee { get; set; } - public Uri WebUrl { get; set; } + public ILocalRepositoryModel LocalRepository { get; set; } - public ReactiveCommand OpenPullRequest { get; } - public ReactiveCommand CreatePullRequest { get; } - public ReactiveCommand OpenPullRequestOnGitHub { get; } + public string SearchQuery { get; set; } public Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) => Task.CompletedTask; } diff --git a/src/GitHub.App/Services/PullRequestService.cs b/src/GitHub.App/Services/PullRequestService.cs index c82bb8f7af..85a33464b4 100644 --- a/src/GitHub.App/Services/PullRequestService.cs +++ b/src/GitHub.App/Services/PullRequestService.cs @@ -1,23 +1,27 @@ using System; +using System.Collections.Generic; using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; -using System.Diagnostics; -using GitHub.Models; +using System.Reactive; using System.Reactive.Linq; -using Rothko; +using System.Reactive.Threading.Tasks; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; -using System.Reactive.Threading.Tasks; +using GitHub.Api; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; using GitHub.Primitives; -using System.Text.RegularExpressions; -using System.Globalization; -using System.Reactive; -using System.Collections.Generic; using LibGit2Sharp; -using GitHub.Logging; -using GitHub.Extensions; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using Rothko; using static System.FormattableString; +using static Octokit.GraphQL.Variable; namespace GitHub.Services { @@ -30,6 +34,7 @@ public class PullRequestService : IPullRequestService static readonly Regex InvalidBranchCharsRegex = new Regex(@"[^0-9A-Za-z\-]", RegexOptions.ECMAScript); static readonly Regex BranchCapture = new Regex(@"branch\.(?.+)\.ghfvs-pr", RegexOptions.ECMAScript); + static ICompiledQuery> readPullRequests; static readonly string[] TemplatePaths = new[] { @@ -42,6 +47,7 @@ public class PullRequestService : IPullRequestService readonly IGitClient gitClient; readonly IGitService gitService; readonly IVSGitExt gitExt; + readonly IGraphQLClientFactory graphqlFactory; readonly IOperatingSystem os; readonly IUsageTracker usageTracker; @@ -50,16 +56,62 @@ public PullRequestService( IGitClient gitClient, IGitService gitService, IVSGitExt gitExt, + IGraphQLClientFactory graphqlFactory, IOperatingSystem os, IUsageTracker usageTracker) { this.gitClient = gitClient; this.gitService = gitService; this.gitExt = gitExt; + this.graphqlFactory = graphqlFactory; this.os = os; this.usageTracker = usageTracker; } + public async Task> ReadPullRequests( + HostAddress address, + string owner, + string name, + string after) + { + if (readPullRequests == null) + { + readPullRequests = new Query() + .Repository(Var(nameof(owner)), Var(nameof(name))) + .PullRequests(first: 100, after: Var(nameof(after)), orderBy: new IssueOrder { Direction = OrderDirection.Desc, Field = IssueOrderField.CreatedAt }) + .Select(page => new Page + { + EndCursor = page.PageInfo.EndCursor, + HasNextPage = page.PageInfo.HasNextPage, + TotalCount = page.TotalCount, + Items = page.Nodes.Select(pr => new PullRequestListItemModel + { + Id = pr.Id.Value, + Author = new ActorModel + { + Login = pr.Author.Login, + AvatarUrl = pr.Author.AvatarUrl(null), + }, + CommentCount = pr.Comments(0, null, null, null).TotalCount, + Number = pr.Number, + State = (PullRequestStateEnum)pr.State, + Title = pr.Title, + UpdatedAt = pr.UpdatedAt, + }).ToList(), + }).Compile(); + } + + var graphql = await graphqlFactory.CreateConnection(address); + var vars = new Dictionary + { + { nameof(owner), owner }, + { nameof(name), name }, + { nameof(after), after }, + }; + + return await graphql.Run(readPullRequests, vars); + } + public IObservable CreatePullRequest(IModelService modelService, ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, IBranch sourceBranch, IBranch targetBranch, diff --git a/src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs b/src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs new file mode 100644 index 0000000000..c8ac63e908 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/IssueListViewModelBase.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; +using GitHub.Collections; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + public abstract class IssueListViewModelBase : PanePageViewModelBase, IIssueListViewModelBase + { + IReadOnlyList items; + ICollectionView itemsView; + string searchQuery; + + public IReadOnlyList Items + { + get { return items; } + private set { this.RaiseAndSetIfChanged(ref items, value); } + } + + public ICollectionView ItemsView + { + get { return itemsView; } + private set { this.RaiseAndSetIfChanged(ref itemsView, value); } + } + + public ILocalRepositoryModel LocalRepository { get; private set; } + + public string SearchQuery + { + get { return searchQuery; } + set { this.RaiseAndSetIfChanged(ref searchQuery, value); } + } + + public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) + { + LocalRepository = repository; + await Load(); + } + + protected abstract IVirtualizingListSource CreateItemSource(); + + Task Load() + { + var items = new VirtualizingList(CreateItemSource(), null); + Items = items; + ItemsView = new VirtualizingListCollectionView(items); + + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs new file mode 100644 index 0000000000..42f2025a35 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListItemViewModel.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + public class PullRequestListItemViewModel : ViewModelBase, IPullRequestListItemViewModel + { + public PullRequestListItemViewModel(PullRequestListItemModel model) + { + Id = model.Id; + Author = new ActorViewModel(model.Author); + CommentCount = model.CommentCount; + Number = model.Number; + Title = model.Title; + UpdatedAt = model.UpdatedAt; + } + + public string Id { get; protected set; } + + public IActorViewModel Author { get; protected set; } + + public int CommentCount { get; protected set; } + + public IReadOnlyList Labels { get; protected set; } + + public int Number { get; protected set; } + + public string Title { get; protected set; } + + public DateTimeOffset UpdatedAt { get; protected set; } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs index 3db0f9e5fa..93753205f2 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs @@ -1,392 +1,62 @@ using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Linq; using System.Threading.Tasks; -using System.Windows.Media.Imaging; -using GitHub.App; using GitHub.Collections; -using GitHub.Extensions; -using GitHub.Factories; -using GitHub.Logging; using GitHub.Models; +using GitHub.Primitives; using GitHub.Services; -using GitHub.Settings; -using ReactiveUI; -using Serilog; -using static System.FormattableString; namespace GitHub.ViewModels.GitHubPane { [Export(typeof(IPullRequestListViewModel))] [PartCreationPolicy(CreationPolicy.NonShared)] - public class PullRequestListViewModel : PanePageViewModelBase, IPullRequestListViewModel + public class PullRequestListViewModel : IssueListViewModelBase, IPullRequestListViewModel { - static readonly ILogger log = LogManager.ForContext(); - - readonly IModelServiceFactory modelServiceFactory; - readonly TrackingCollection trackingAuthors; - readonly TrackingCollection trackingAssignees; - readonly IPackageSettings settings; - readonly IVisualStudioBrowser visualStudioBrowser; - readonly bool constructing; - PullRequestListUIState listSettings; - ILocalRepositoryModel localRepository; - IRemoteRepositoryModel remoteRepository; - IModelService modelService; + readonly IPullRequestService service; [ImportingConstructor] - public PullRequestListViewModel( - IModelServiceFactory modelServiceFactory, - IPackageSettings settings, - IPullRequestSessionManager sessionManager, - IVisualStudioBrowser visualStudioBrowser) + public PullRequestListViewModel(IPullRequestService service) { - Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); - Guard.ArgumentNotNull(settings, nameof(settings)); - Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); - Guard.ArgumentNotNull(visualStudioBrowser, nameof(visualStudioBrowser)); - - constructing = true; - this.modelServiceFactory = modelServiceFactory; - this.settings = settings; - this.visualStudioBrowser = visualStudioBrowser; - - Title = Resources.PullRequestsNavigationItemText; - - States = new List { - new PullRequestState { IsOpen = true, Name = "Open" }, - new PullRequestState { IsOpen = false, Name = "Closed" }, - new PullRequestState { Name = "All" } - }; - - trackingAuthors = new TrackingCollection(Observable.Empty(), - OrderedComparer.OrderByDescending(x => x.Login).Compare); - trackingAssignees = new TrackingCollection(Observable.Empty(), - OrderedComparer.OrderByDescending(x => x.Login).Compare); - trackingAuthors.Subscribe(); - trackingAssignees.Subscribe(); - - Authors = trackingAuthors.CreateListenerCollection(EmptyUser, this.WhenAnyValue(x => x.SelectedAuthor)); - Assignees = trackingAssignees.CreateListenerCollection(EmptyUser, this.WhenAnyValue(x => x.SelectedAssignee)); - - CreatePullRequests(); - - this.WhenAny(x => x.SelectedState, x => x.Value) - .Where(x => PullRequests != null) - .Subscribe(s => UpdateFilter(s, SelectedAssignee, SelectedAuthor, SearchQuery)); - - this.WhenAny(x => x.SelectedAssignee, x => x.Value) - .Where(x => PullRequests != null && x != EmptyUser) - .Subscribe(a => UpdateFilter(SelectedState, a, SelectedAuthor, SearchQuery)); - - this.WhenAny(x => x.SelectedAuthor, x => x.Value) - .Where(x => PullRequests != null && x != EmptyUser) - .Subscribe(a => UpdateFilter(SelectedState, SelectedAssignee, a, SearchQuery)); - - this.WhenAny(x => x.SearchQuery, x => x.Value) - .Where(x => PullRequests != null) - .Subscribe(f => UpdateFilter(SelectedState, SelectedAssignee, SelectedAuthor, f)); - - this.WhenAnyValue(x => x.SelectedRepository) - .Skip(1) - .Subscribe(_ => ResetAndLoad()); - - OpenPullRequest = ReactiveCommand.Create(); - OpenPullRequest.Subscribe(DoOpenPullRequest); - CreatePullRequest = ReactiveCommand.Create(); - CreatePullRequest.Subscribe(_ => DoCreatePullRequest()); - - OpenPullRequestOnGitHub = ReactiveCommand.Create(); - OpenPullRequestOnGitHub.Subscribe(x => DoOpenPullRequestOnGitHub((int)x)); - - // Get the current pull request session and the selected repository. When the session's - // repository is the same as our selected repository set CheckedOutPullRequest to the - // current session's model, so that the checked out PR can be highlighted. - Observable.CombineLatest( - sessionManager.WhenAnyValue(x => x.CurrentSession), - this.WhenAnyValue(x => x.SelectedRepository), - (s, r) => new { Session = s, Repository = r }) - .Subscribe(x => - { - CheckedOutPullRequest = x.Session?.RepositoryOwner == x.Repository?.Owner ? - x.Session?.PullRequest : null; - }); - - constructing = false; + this.service = service; } - public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection) + protected override IVirtualizingListSource CreateItemSource() { - IsLoading = true; - - try - { - modelService = await modelServiceFactory.CreateAsync(connection); - 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]; - - // Setting SelectedRepository will cause a Load(). - SelectedRepository = Repositories[0]; - } - finally - { - IsLoading = false; - } + return new ItemSource(this); } - public override Task Refresh() => Load(); - - Task Load() + class ItemSource : SequentialListSource { - IsBusy = true; + readonly PullRequestListViewModel owner; - PullRequests = modelService.GetPullRequests(SelectedRepository, pullRequests); - pullRequests.Subscribe(pr => + public ItemSource(PullRequestListViewModel owner) { - trackingAssignees.AddItem(pr.Assignee); - trackingAuthors.AddItem(pr.Author); - }, () => { }); - - pullRequests.OriginalCompleted - .ObserveOn(RxApp.MainThreadScheduler) - .Catch(ex => - { - // Occurs on network error, when the repository was deleted on GitHub etc. - log.Error(ex, "Received Exception reading pull requests"); - return Observable.Empty(); - }) - .Subscribe(_ => - { - if (listSettings.SelectedAuthor != null) - { - SelectedAuthor = Authors.FirstOrDefault(x => x.Login == listSettings.SelectedAuthor); - } - - if (listSettings.SelectedAssignee != null) - { - SelectedAssignee = Assignees.FirstOrDefault(x => x.Login == listSettings.SelectedAssignee); - } - - IsBusy = false; - UpdateFilter(SelectedState, SelectedAssignee, SelectedAuthor, SearchQuery); - }); - return Task.CompletedTask; - } - - void UpdateFilter(PullRequestState state, IAccount ass, IAccount aut, string filText) - { - if (PullRequests == null) - return; - - var filterTextIsNumber = false; - var filterTextIsString = false; - var filterPullRequestNumber = 0; + this.owner = owner; + } - if (filText != null) + protected override IViewModel CreateViewModel(PullRequestListItemModel model) { - filText = filText.Trim(); - - var hasText = !string.IsNullOrEmpty(filText); - - if (hasText && filText.StartsWith("#", StringComparison.CurrentCultureIgnoreCase)) - { - filterTextIsNumber = int.TryParse(filText.Substring(1), out filterPullRequestNumber); - } - else - { - filterTextIsNumber = int.TryParse(filText, out filterPullRequestNumber); - } - - filterTextIsString = hasText && !filterTextIsNumber; + return new PullRequestListItemViewModel(model); } - if (!pullRequests.Disposed) + protected override Task> LoadPage(string after) { - pullRequests.Filter = (pullRequest, index, list) => - (!state.IsOpen.HasValue || state.IsOpen == pullRequest.IsOpen) && - (ass == null || ass.Equals(pullRequest.Assignee)) && - (aut == null || aut.Equals(pullRequest.Author)) && - (filterTextIsNumber == false || pullRequest.Number == filterPullRequestNumber) && - (filterTextIsString == false || pullRequest.Title.ToUpperInvariant().Contains(filText.ToUpperInvariant())); + return owner.service.ReadPullRequests( + HostAddress.Create(owner.LocalRepository.CloneUrl), + owner.LocalRepository.Owner, + owner.LocalRepository.Name, + after); } - SaveSettings(); - } - - string searchQuery; - public string SearchQuery - { - get { return searchQuery; } - set { this.RaiseAndSetIfChanged(ref searchQuery, value); } - } - - IReadOnlyList repositories; - public IReadOnlyList Repositories - { - get { return repositories; } - private set { this.RaiseAndSetIfChanged(ref repositories, value); } - } - - IRemoteRepositoryModel selectedRepository; - public IRemoteRepositoryModel SelectedRepository - { - get { return selectedRepository; } - set { this.RaiseAndSetIfChanged(ref selectedRepository, value); } - } - - ITrackingCollection pullRequests; - public ITrackingCollection PullRequests - { - get { return pullRequests; } - private set { this.RaiseAndSetIfChanged(ref pullRequests, value); } - } - - IPullRequestModel selectedPullRequest; - public IPullRequestModel SelectedPullRequest - { - get { return selectedPullRequest; } - set { this.RaiseAndSetIfChanged(ref selectedPullRequest, value); } - } - - PullRequestDetailModel checkedOutPullRequest; - public PullRequestDetailModel CheckedOutPullRequest - { - get { return checkedOutPullRequest; } - set { this.RaiseAndSetIfChanged(ref checkedOutPullRequest, value); } - } - - IReadOnlyList states; - public IReadOnlyList States - { - get { return states; } - set { this.RaiseAndSetIfChanged(ref states, value); } - } - - PullRequestState selectedState; - public PullRequestState SelectedState - { - get { return selectedState; } - set { this.RaiseAndSetIfChanged(ref selectedState, value); } - } - - ObservableCollection assignees; - public ObservableCollection Assignees - { - get { return assignees; } - set { this.RaiseAndSetIfChanged(ref assignees, value); } - } - - ObservableCollection authors; - public ObservableCollection Authors - { - get { return authors; } - set { this.RaiseAndSetIfChanged(ref authors, value); } - } - - IAccount selectedAuthor; - public IAccount SelectedAuthor - { - get { return selectedAuthor; } - set { this.RaiseAndSetIfChanged(ref selectedAuthor, value); } - } - - IAccount selectedAssignee; - public IAccount SelectedAssignee - { - get { return selectedAssignee; } - set { this.RaiseAndSetIfChanged(ref selectedAssignee, value); } - } - - IAccount emptyUser = new Account("[None]", false, false, 0, 0, string.Empty, Observable.Empty()); - public IAccount EmptyUser - { - get { return emptyUser; } - } - - Uri webUrl; - public Uri WebUrl - { - get { return webUrl; } - private set { this.RaiseAndSetIfChanged(ref webUrl, value); } - } - - public bool IsSearchEnabled => true; - - public ReactiveCommand OpenPullRequest { get; } - public ReactiveCommand CreatePullRequest { get; } - public ReactiveCommand OpenPullRequestOnGitHub { get; } - - bool disposed; - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - if (disposing) + protected override void OnBeginLoading() { - if (disposed) return; - pullRequests.Dispose(); - trackingAuthors.Dispose(); - trackingAssignees.Dispose(); - disposed = true; + owner.IsBusy = true; } - } - - void CreatePullRequests() - { - PullRequests = new TrackingCollection(); - pullRequests.Comparer = OrderedComparer.OrderByDescending(x => x.UpdatedAt).Compare; - pullRequests.NewerComparer = OrderedComparer.OrderByDescending(x => x.UpdatedAt).Compare; - } - void ResetAndLoad() - { - WebUrl = SelectedRepository.CloneUrl?.ToRepositoryUrl().Append("pulls"); - CreatePullRequests(); - UpdateFilter(SelectedState, SelectedAssignee, SelectedAuthor, SearchQuery); - Load().Forget(); - } - - void SaveSettings() - { - if (!constructing) + protected override void OnEndLoading() { - listSettings.SelectedState = SelectedState.Name; - listSettings.SelectedAssignee = SelectedAssignee?.Login; - listSettings.SelectedAuthor = SelectedAuthor?.Login; - settings.Save(); + owner.IsBusy = false; } } - - void DoOpenPullRequest(object pullRequest) - { - Guard.ArgumentNotNull(pullRequest, nameof(pullRequest)); - - var number = (int)pullRequest; - NavigateTo(Invariant($"{SelectedRepository.Owner}/{SelectedRepository.Name}/pull/{number}")); - } - - void DoCreatePullRequest() - { - NavigateTo("pull/new"); - } - - void DoOpenPullRequestOnGitHub(int pullRequest) - { - var repoUrl = SelectedRepository.CloneUrl.ToRepositoryUrl(); - var url = repoUrl.Append("pull/" + pullRequest); - visualStudioBrowser.OpenUrl(url); - } } } diff --git a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj index fd7af642f7..e5d03d4189 100644 --- a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj +++ b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj @@ -198,10 +198,12 @@ + + diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs index 15d130265b..3f2b37091e 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs @@ -4,12 +4,27 @@ using System.Text; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Primitives; using LibGit2Sharp; namespace GitHub.Services { public interface IPullRequestService { + /// + /// Reads a page of pull request items using GraphQL. + /// + /// The host address. + /// The repository owner. + /// The repository name. + /// The end cursor of the previous page, or null for the first page. + /// A page of pull request item models. + Task> ReadPullRequests( + HostAddress address, + string owner, + string name, + string after); + IObservable CreatePullRequest(IModelService modelService, ILocalRepositoryModel sourceRepository, IRepositoryModel targetRepository, IBranch sourceBranch, IBranch targetBranch, diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs new file mode 100644 index 0000000000..bca5815bde --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IIssueListViewModelBase.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.GitHubPane +{ + public interface IIssueListViewModelBase : ISearchablePageViewModel + { + IReadOnlyList Items { get; } + + ICollectionView ItemsView { get; } + + ILocalRepositoryModel LocalRepository { get; } + + Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs new file mode 100644 index 0000000000..1e252a4bd1 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListItemViewModel.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents an item in the pull request list. + /// + public interface IPullRequestListItemViewModel : IViewModel + { + /// + /// Gets the ID of the issue or pull request. + /// + string Id { get; } + + /// + /// Gets the author of the issue or pull request. + /// + IActorViewModel Author { get; } + + /// + /// Gets the number of comments in the issue or pull request. + /// + int CommentCount { get; } + + /// + /// Gets the labels applied to the issue or pull request. + /// + IReadOnlyList Labels { get; } + + /// + /// Gets the issue or pull request number. + /// + int Number { get; } + + /// + /// Gets the issue or pull request title. + /// + string Title { get; } + + /// + /// Gets the last updated time of the issue or pull request. + /// + DateTimeOffset UpdatedAt { get; } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs index f4a2d1b782..e89be64d1f 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs @@ -1,49 +1,8 @@ -using System.Collections.Generic; -using GitHub.Collections; -using GitHub.Models; -using ReactiveUI; -using System.Collections.ObjectModel; -using System.Threading.Tasks; +using System; namespace GitHub.ViewModels.GitHubPane { - public class PullRequestState + public interface IPullRequestListViewModel : IIssueListViewModelBase { - public PullRequestState() - { - } - - public PullRequestState(bool isOpen, string name) - { - IsOpen = isOpen; - Name = name; - } - - public bool? IsOpen; - public string Name; - public override string ToString() - { - return Name; - } - } - - public interface IPullRequestListViewModel : ISearchablePageViewModel, IOpenInBrowser - { - IReadOnlyList Repositories { get; } - IRemoteRepositoryModel SelectedRepository { get; set; } - ITrackingCollection PullRequests { get; } - IPullRequestModel SelectedPullRequest { get; } - PullRequestDetailModel CheckedOutPullRequest { get; } - IReadOnlyList States { get; set; } - PullRequestState SelectedState { get; set; } - ObservableCollection Authors { get; } - IAccount SelectedAuthor { get; set; } - ObservableCollection Assignees { get; } - IAccount SelectedAssignee { get; set; } - ReactiveCommand OpenPullRequest { get; } - ReactiveCommand CreatePullRequest { get; } - ReactiveCommand OpenPullRequestOnGitHub { get; } - - Task InitializeAsync(ILocalRepositoryModel repository, IConnection connection); } } diff --git a/src/GitHub.Exports/Collections/IVirtualizingListSource.cs b/src/GitHub.Exports/Collections/IVirtualizingListSource.cs new file mode 100644 index 0000000000..4f801c977b --- /dev/null +++ b/src/GitHub.Exports/Collections/IVirtualizingListSource.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace GitHub.Collections +{ + public interface IVirtualizingListSource + { + int PageSize { get; } + Task GetCount(); + Task> GetPage(int pageNumber); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/GitHub.Exports.csproj b/src/GitHub.Exports/GitHub.Exports.csproj index b127580afb..dfebc440bf 100644 --- a/src/GitHub.Exports/GitHub.Exports.csproj +++ b/src/GitHub.Exports/GitHub.Exports.csproj @@ -143,6 +143,7 @@ + @@ -172,8 +173,10 @@ + + @@ -188,6 +191,7 @@ + diff --git a/src/GitHub.Exports/Models/Page.cs b/src/GitHub.Exports/Models/Page.cs new file mode 100644 index 0000000000..3d099d9e28 --- /dev/null +++ b/src/GitHub.Exports/Models/Page.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Represents a page in a GraphQL paged collection. + /// + /// The item type. + public class Page + { + /// + /// Gets or sets the cursor for the last item. + /// + public string EndCursor { get; set; } + + /// + /// Gets or sets a value indicating whether there are more items after this page. + /// + public bool HasNextPage { get; set; } + + /// + /// Gets or sets the total count of items in all pages. + /// + public int TotalCount { get; set; } + + /// + /// Gets or sets the items in the page. + /// + public IList Items { get; set; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Models/PullRequestListItemModel.cs b/src/GitHub.Exports/Models/PullRequestListItemModel.cs new file mode 100644 index 0000000000..21f6030429 --- /dev/null +++ b/src/GitHub.Exports/Models/PullRequestListItemModel.cs @@ -0,0 +1,45 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Holds an overview of a pull request for display in the PR list. + /// + public class PullRequestListItemModel + { + /// + /// Gets or sets the GraphQL ID of the pull request. + /// + public string Id { get; set; } + + /// + /// Gets or sets the pull request number. + /// + public int Number { get; set; } + + /// + /// Gets or sets the pull request author. + /// + public ActorModel Author { get; set; } + + /// + /// Gets or sets the number of comments on the pull request. + /// + public int CommentCount { get; set; } + + /// + /// Gets or sets the pull request title. + /// + public string Title { get; set; } + + /// + /// Gets or sets the pull request state (open, closed, merged). + /// + public PullRequestStateEnum State { get; set; } + + /// + /// Gets or sets the date/time at which the pull request was last updated. + /// + public DateTimeOffset UpdatedAt { get; set; } + } +} diff --git a/src/GitHub.Exports/ViewModels/ILabelViewModel.cs b/src/GitHub.Exports/ViewModels/ILabelViewModel.cs new file mode 100644 index 0000000000..7c0767f271 --- /dev/null +++ b/src/GitHub.Exports/ViewModels/ILabelViewModel.cs @@ -0,0 +1,25 @@ +using System.Windows.Media; + +namespace GitHub.ViewModels +{ + /// + /// View model for an issue/pull request label. + /// + public interface ILabelViewModel + { + /// + /// Gets the background brush for the label. + /// + Brush BackgroundBrush { get; } + + /// + /// Gets the foreground brush for the label. + /// + Brush ForegroundBrush { get; } + + /// + /// Gets the name of the label. + /// + string Name { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj index 17086d9683..b029cf8aeb 100644 --- a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj +++ b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj @@ -396,6 +396,9 @@ PullRequestFileCommentsView.xaml + + PullRequestListItemView.xaml + PullRequestReviewAuthoringView.xaml @@ -572,6 +575,10 @@ MSBuild:Compile Designer + + Designer + MSBuild:Compile + MSBuild:Compile Designer @@ -604,10 +611,6 @@ MSBuild:Compile Designer - - MSBuild:Compile - Designer - MSBuild:Compile Designer diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml deleted file mode 100644 index 8d7f0ad589..0000000000 --- a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestListItem.xaml +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -