diff --git a/src/GitHub.App/Models/PullRequestModel.cs b/src/GitHub.App/Models/PullRequestModel.cs index 6a560fa2ea..c1f6bd3837 100644 --- a/src/GitHub.App/Models/PullRequestModel.cs +++ b/src/GitHub.App/Models/PullRequestModel.cs @@ -110,8 +110,8 @@ public string Title } } - PullRequestStateEnum status; - public PullRequestStateEnum State + PullRequestState status; + public PullRequestState State { get { return status; } set @@ -126,8 +126,8 @@ public PullRequestStateEnum State } // TODO: Remove these property once maintainer workflow has been merged to master. - public bool IsOpen => State == PullRequestStateEnum.Open; - public bool Merged => State == PullRequestStateEnum.Merged; + public bool IsOpen => State == PullRequestState.Open; + public bool Merged => State == PullRequestState.Merged; int commentCount; public int CommentCount diff --git a/src/GitHub.App/Properties/AssemblyInfo.cs b/src/GitHub.App/Properties/AssemblyInfo.cs index 20fe17f77f..0ad9954b61 100644 --- a/src/GitHub.App/Properties/AssemblyInfo.cs +++ b/src/GitHub.App/Properties/AssemblyInfo.cs @@ -2,7 +2,9 @@ [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData.Dialog.Clone")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.SampleData.Documents")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Dialog.Clone")] +[assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.Documents")] [assembly: XmlnsDefinition("https://github.com/github/VisualStudio", "GitHub.ViewModels.GitHubPane")] diff --git a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs index 425f536ad7..b060088de9 100644 --- a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; using GitHub.ViewModels; using ReactiveUI; @@ -22,7 +24,9 @@ public CommentViewModelDesigner() public CommentEditState EditState { get; set; } public bool IsReadOnly { get; set; } public bool IsSubmitting { get; set; } + public bool CanCancel { get; } = true; public bool CanDelete { get; } = true; + public string CommitCaption { get; set; } = "Comment"; public ICommentThreadViewModel Thread { get; } public DateTimeOffset CreatedAt => DateTime.Now.Subtract(TimeSpan.FromDays(3)); public IActorViewModel Author { get; set; } @@ -31,7 +35,12 @@ public CommentViewModelDesigner() public ReactiveCommand BeginEdit { get; } public ReactiveCommand CancelEdit { get; } public ReactiveCommand CommitEdit { get; } - public ReactiveCommand OpenOnGitHub { get; } + public ReactiveCommand OpenOnGitHub { get; } = ReactiveCommand.Create(() => { }); public ReactiveCommand Delete { get; } + + public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state) + { + return Task.CompletedTask; + } } } diff --git a/src/GitHub.App/SampleData/Documents/IssueishCommentThreadViewModelDesigner.cs b/src/GitHub.App/SampleData/Documents/IssueishCommentThreadViewModelDesigner.cs new file mode 100644 index 0000000000..888f09cd73 --- /dev/null +++ b/src/GitHub.App/SampleData/Documents/IssueishCommentThreadViewModelDesigner.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Documents; +using ReactiveUI; + +namespace GitHub.SampleData.Documents +{ + public class IssueishCommentThreadViewModelDesigner : ViewModelBase, IIssueishCommentThreadViewModel + { + public IActorViewModel CurrentUser { get; } = new ActorViewModelDesigner("grokys"); + public Task InitializeAsync(ActorModel currentUser, IssueishDetailModel model, bool addPlaceholder) => Task.CompletedTask; + public Task DeleteComment(ICommentViewModel comment) => Task.CompletedTask; + public Task EditComment(ICommentViewModel comment) => Task.CompletedTask; + public Task PostComment(ICommentViewModel comment) => Task.CompletedTask; + public Task CloseOrReopen(ICommentViewModel comment) => Task.CompletedTask; + } +} diff --git a/src/GitHub.App/SampleData/Documents/PullRequestPageViewModelDesigner.cs b/src/GitHub.App/SampleData/Documents/PullRequestPageViewModelDesigner.cs new file mode 100644 index 0000000000..c334e08489 --- /dev/null +++ b/src/GitHub.App/SampleData/Documents/PullRequestPageViewModelDesigner.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.ViewModels; +using GitHub.ViewModels.Documents; +using ReactiveUI; + +namespace GitHub.SampleData.Documents +{ + public class PullRequestPageViewModelDesigner : ViewModelBase, IPullRequestPageViewModel + { + public PullRequestPageViewModelDesigner() + { + Body = @"Save drafts of inline comments, PR reviews and PRs. + +> Note: This feature required a refactoring of the comment view models because they now need async initialization and to be available from GitHub.App. This part of the PR has been submitted separately as #1993 to ease review. The two PRs can alternatively be reviewed as one if that's more convenient. + +As described in #1905, it is easy to lose a comment that you're working on if you close the diff view accidentally. This PR saves drafts of comments as they are being written to an SQLite database. + +In addition to saving drafts of inline comments, it also saves comments to PR reviews and PRs themselves. + +The comments are written to an SQLite database directly instead of going through Akavache because in the case of inline reviews, there can be many drafts in progress on a separate file. When a diff is opened we need to look for any comments present on that file and show the most recent. That use-case didn't fit well with Akavache (being a pure key/value store). + +## Testing + +### Inline Comments + +- Open a PR +- Open the diff of a file +- Start adding a comment +- Close the comment by closing the peek view, or the document tab +- Reopen the diff +- You should see the comment displayed in edit mode with the draft of the comment you were previously writing + +### PR reviews + +- Open a PR +- Click ""Add your review"" +- Start adding a review +- Click the ""Back"" button and navigate to a different PR +- Click the ""Back"" button and navigate to the original PR +- Click ""Add your review"" +- You should see the the draft of the review you were previously writing + +### PRs + +-Click ""Create new"" at the top of the PR list +- Start adding a PR title/ description +- Close VS +- Restart VS and click ""Create new"" again +- You should see the the draft of the PR you were previously writing + +Depends on #1993 +Fixes #1905"; + Timeline = new IViewModel[] + { + new CommitListViewModel( + new CommitSummaryViewModel(new CommitModel + { + Author = new ActorModel { Login = "grokys" }, + AbbreviatedOid = "c7c7d25", + MessageHeadline = "Refactor comment view models." + }), + new CommitSummaryViewModel(new CommitModel + { + Author = new ActorModel { Login = "shana" }, + AbbreviatedOid = "04e6a90", + MessageHeadline = "Refactor comment view models.", + })), + new CommentViewModelDesigner + { + Author = new ActorViewModelDesigner("meaghanlewis"), + Body = @"This is looking great! Really enjoying using this feature so far. + +When leaving an inline comment, the comment posts successfully and then a new comment is drafted with the same text.", + }, + new CommentViewModelDesigner + { + Author = new ActorViewModelDesigner("grokys"), + Body = @"Oops, sorry about that @meaghanlewis - I was sure I tested those things, but must have got messed up again at some point. Should be fixed now.", + }, + }; + } + + public string Id { get; set; } + public PullRequestState State { get; set; } = PullRequestState.Open; + public IReadOnlyList Timeline { get; } + public string SourceBranchDisplayName { get; set; } = "feature/save-drafts"; + public string TargetBranchDisplayName { get; set; } = "master"; + public IActorViewModel Author { get; set; } = new ActorViewModelDesigner("grokys"); + public int CommitCount { get; set; } = 2; + public string Body { get; set; } + public int Number { get; set; } = 1994; + public LocalRepositoryModel LocalRepository { get; } + public RemoteRepositoryModel Repository { get; set; } + public string Title { get; set; } = "Save drafts of comments"; + public Uri WebUrl { get; set; } + public ReactiveCommand OpenOnGitHub { get; } + public ReactiveCommand ShowCommit { get; } + + + public Task InitializeAsync(RemoteRepositoryModel repository, LocalRepositoryModel localRepository, ActorModel currentUser, PullRequestDetailModel model) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs index 9f1ad0262a..1b99d9b7bf 100644 --- a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs @@ -124,6 +124,7 @@ public PullRequestDetailViewModelDesigner() public ReactiveCommand Pull { get; } public ReactiveCommand Push { get; } public ReactiveCommand SyncSubmodules { get; } + public ReactiveCommand OpenConversation { get; } public ReactiveCommand OpenOnGitHub { get; } public ReactiveCommand ShowReview { get; } public ReactiveCommand ShowAnnotations { get; } diff --git a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs index 46b572c5d9..146d35ed46 100644 --- a/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestListViewModelDesigner.cs @@ -63,6 +63,7 @@ public PullRequestListViewModelDesigner() public IReadOnlyList States { get; } public Uri WebUrl => null; public ReactiveCommand CreatePullRequest { get; } + public ReactiveCommand OpenConversation { get; } public ReactiveCommand OpenItem { get; } public ReactiveCommand OpenItemInBrowser { get; } diff --git a/src/GitHub.App/Services/FromGraphQlExtensions.cs b/src/GitHub.App/Services/FromGraphQlExtensions.cs index d9b79bcece..0bbd1ce321 100644 --- a/src/GitHub.App/Services/FromGraphQlExtensions.cs +++ b/src/GitHub.App/Services/FromGraphQlExtensions.cs @@ -34,16 +34,16 @@ public static class FromGraphQlExtensions } } - public static PullRequestStateEnum FromGraphQl(this PullRequestState value) + public static Models.PullRequestState FromGraphQl(this Octokit.GraphQL.Model.PullRequestState value) { switch (value) { - case PullRequestState.Open: - return PullRequestStateEnum.Open; - case PullRequestState.Closed: - return PullRequestStateEnum.Closed; - case PullRequestState.Merged: - return PullRequestStateEnum.Merged; + case Octokit.GraphQL.Model.PullRequestState.Open: + return Models.PullRequestState.Open; + case Octokit.GraphQL.Model.PullRequestState.Closed: + return Models.PullRequestState.Closed; + case Octokit.GraphQL.Model.PullRequestState.Merged: + return Models.PullRequestState.Merged; default: throw new ArgumentOutOfRangeException(nameof(value), value, null); } diff --git a/src/GitHub.App/Services/GitClient.cs b/src/GitHub.App/Services/GitClient.cs index 6114d82169..0216d187bf 100644 --- a/src/GitHub.App/Services/GitClient.cs +++ b/src/GitHub.App/Services/GitClient.cs @@ -181,6 +181,11 @@ public Task Checkout(IRepository repository, string branchName) }); } + public async Task CommitExists(IRepository repository, string sha) + { + return await Task.Run(() => repository.Lookup(sha) != null).ConfigureAwait(false); + } + public Task CreateBranch(IRepository repository, string branchName) { Guard.ArgumentNotNull(repository, nameof(repository)); diff --git a/src/GitHub.App/Services/IssueishService.cs b/src/GitHub.App/Services/IssueishService.cs new file mode 100644 index 0000000000..481d9f7f3c --- /dev/null +++ b/src/GitHub.App/Services/IssueishService.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GitHub.Api; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Primitives; +using Octokit; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using static Octokit.GraphQL.Variable; + +namespace GitHub.Services +{ + /// + /// Base class for issue and pull request services. + /// + public abstract class IssueishService : IIssueishService + { + static ICompiledQuery postComment; + readonly IApiClientFactory apiClientFactory; + readonly IGraphQLClientFactory graphqlFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The API client factory. + /// The GraphQL client factory. + public IssueishService( + IApiClientFactory apiClientFactory, + IGraphQLClientFactory graphqlFactory) + { + this.apiClientFactory = apiClientFactory; + this.graphqlFactory = graphqlFactory; + } + + /// + public async Task CloseIssueish(HostAddress address, string owner, string repository, int number) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + var update = new IssueUpdate { State = ItemState.Closed }; + await client.Issue.Update(owner, repository, number, update).ConfigureAwait(false); + } + + /// + public async Task ReopenIssueish(HostAddress address, string owner, string repository, int number) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + var update = new IssueUpdate { State = ItemState.Open }; + await client.Issue.Update(owner, repository, number, update).ConfigureAwait(false); + } + + /// + public async Task PostComment(HostAddress address, string issueishId, string body) + { + var input = new AddCommentInput + { + Body = body, + SubjectId = new ID(issueishId), + }; + + if (postComment == null) + { + postComment = new Mutation() + .AddComment(Var(nameof(input))) + .CommentEdge + .Node + .Select(comment => new CommentModel + { + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Id = comment.Id.Value, + Url = comment.Url, + }).Compile(); + } + + var vars = new Dictionary + { + { nameof(input), input }, + }; + + var graphql = await graphqlFactory.CreateConnection(address).ConfigureAwait(false); + return await graphql.Run(postComment, vars).ConfigureAwait(false); + } + + public async Task DeleteComment( + HostAddress address, + string owner, + string repository, + int commentId) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + await client.Issue.Comment.Delete(owner, repository, commentId).ConfigureAwait(false); + } + + public async Task EditComment( + HostAddress address, + string owner, + string repository, + int commentId, + string body) + { + var client = await apiClientFactory.CreateGitHubClient(address).ConfigureAwait(false); + await client.Issue.Comment.Update(owner, repository, commentId, body).ConfigureAwait(false); + } + } +} diff --git a/src/GitHub.App/Services/ModelService.cs b/src/GitHub.App/Services/ModelService.cs index 289c4bfc50..0a9f7e03a6 100644 --- a/src/GitHub.App/Services/ModelService.cs +++ b/src/GitHub.App/Services/ModelService.cs @@ -390,7 +390,7 @@ IPullRequestModel Create(PullRequestCacheItem prCacheItem) Head = Create(prCacheItem.Head), State = prCacheItem.State.HasValue ? prCacheItem.State.Value : - prCacheItem.IsOpen.Value ? PullRequestStateEnum.Open : PullRequestStateEnum.Closed, + prCacheItem.IsOpen.Value ? PullRequestState.Open : PullRequestState.Closed, }; } @@ -524,25 +524,25 @@ public PullRequestCacheItem(PullRequest pr) public string Body { get; set; } // Nullable for compatibility with old caches. - public PullRequestStateEnum? State { get; set; } + public PullRequestState? State { get; set; } // This fields exists only for compatibility with old caches. The State property should be used. public bool? IsOpen { get; set; } public bool? Merged { get; set; } - static PullRequestStateEnum GetState(PullRequest pullRequest) + static PullRequestState GetState(PullRequest pullRequest) { if (pullRequest.State == ItemState.Open) { - return PullRequestStateEnum.Open; + return PullRequestState.Open; } else if (pullRequest.Merged) { - return PullRequestStateEnum.Merged; + return PullRequestState.Merged; } else { - return PullRequestStateEnum.Closed; + return PullRequestState.Closed; } } } diff --git a/src/GitHub.App/Services/PullRequestService.cs b/src/GitHub.App/Services/PullRequestService.cs index 107fc3017e..90ac1d1b77 100644 --- a/src/GitHub.App/Services/PullRequestService.cs +++ b/src/GitHub.App/Services/PullRequestService.cs @@ -17,6 +17,7 @@ using GitHub.Api; using GitHub.App.Services; using GitHub.Extensions; +using GitHub.Factories; using GitHub.Logging; using GitHub.Models; using GitHub.Primitives; @@ -35,7 +36,7 @@ namespace GitHub.Services { [Export(typeof(IPullRequestService))] [PartCreationPolicy(CreationPolicy.Shared)] - public class PullRequestService : IPullRequestService, IStaticReviewFileMap + public class PullRequestService : IssueishService, IPullRequestService, IStaticReviewFileMap { const string SettingCreatedByGHfVS = "created-by-ghfvs"; const string SettingGHfVSPullRequest = "ghfvs-pr-owner-number"; @@ -68,9 +69,11 @@ public PullRequestService( IGitClient gitClient, IGitService gitService, IVSGitExt gitExt, + IApiClientFactory apiClientFactory, IGraphQLClientFactory graphqlFactory, IOperatingSystem os, IUsageTracker usageTracker) + : base(apiClientFactory, graphqlFactory) { this.gitClient = gitClient; this.gitService = gitService; @@ -86,7 +89,7 @@ public async Task> ReadPullRequests( string owner, string name, string after, - PullRequestStateEnum[] states) + Models.PullRequestState[] states) { ICompiledQuery> query; @@ -212,7 +215,7 @@ public async Task> ReadPullRequests( { nameof(owner), owner }, { nameof(name), name }, { nameof(after), after }, - { nameof(states), states.Select(x => (PullRequestState)x).ToList() }, + { nameof(states), states.Select(x => (Octokit.GraphQL.Model.PullRequestState)x).ToList() }, }; var result = await graphql.Run(query, vars); @@ -576,6 +579,21 @@ public IObservable Checkout(LocalRepositoryModel repository, PullRequestDe }); } + public async Task FetchCommit(LocalRepositoryModel localRepository, RepositoryModel remoteRepository, string sha) + { + using (var repo = gitService.GetRepository(localRepository.LocalPath)) + { + if (!await gitClient.CommitExists(repo, sha).ConfigureAwait(false)) + { + var remote = await CreateRemote(repo, remoteRepository.CloneUrl).ConfigureAwait(false); + await gitClient.Fetch(repo, remote).ConfigureAwait(false); + return await gitClient.CommitExists(repo, sha).ConfigureAwait(false); + } + + return true; + } + } + public IObservable GetDefaultLocalBranchName(LocalRepositoryModel repository, int pullRequestNumber, string pullRequestTitle) { return Observable.Defer(() => diff --git a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs index 7b8bf842f9..ce3caf74a2 100644 --- a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs +++ b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs @@ -18,7 +18,6 @@ namespace GitHub.ViewModels /// public abstract class CommentThreadViewModel : ReactiveObject, ICommentThreadViewModel { - readonly ReactiveList comments = new ReactiveList(); readonly Dictionary> draftThrottles = new Dictionary>(); readonly IScheduler timerScheduler; @@ -51,15 +50,9 @@ public CommentThreadViewModel( this.timerScheduler = timerScheduler; } - /// - public IReactiveList Comments => comments; - /// public IActorViewModel CurrentUser { get; private set; } - /// - IReadOnlyReactiveList ICommentThreadViewModel.Comments => comments; - protected IMessageDraftStore DraftStore { get; } /// @@ -72,15 +65,13 @@ public CommentThreadViewModel( public abstract Task DeleteComment(ICommentViewModel comment); /// - /// Adds a placeholder comment that will allow the user to enter a reply, and wires up + /// Initializes a placeholder comment that will allow the user to enter a reply, and wires up /// event listeners for saving drafts. /// /// The placeholder comment view model. /// An object which when disposed will remove the event listeners. - protected IDisposable AddPlaceholder(ICommentViewModel placeholder) + protected IDisposable InitializePlaceholder(ICommentViewModel placeholder) { - Comments.Add(placeholder); - return placeholder.WhenAnyValue( x => x.EditState, x => x.Body, diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs index 259535f0c1..b8a640742b 100644 --- a/src/GitHub.App/ViewModels/CommentViewModel.cs +++ b/src/GitHub.App/ViewModels/CommentViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.Composition; using System.Linq; using System.Reactive; using System.Reactive.Linq; @@ -13,13 +14,17 @@ namespace GitHub.ViewModels { /// - /// Base view model for an issue or pull request comment. + /// An issue or pull request comment. /// - public abstract class CommentViewModel : ReactiveObject, ICommentViewModel + [Export(typeof(ICommentViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class CommentViewModel : ViewModelBase, ICommentViewModel { static readonly ILogger log = LogManager.ForContext(); readonly ICommentService commentService; + readonly ObservableAsPropertyHelper canCancel; readonly ObservableAsPropertyHelper canDelete; + ObservableAsPropertyHelper commitCaption; string id; IActorViewModel author; IActorViewModel currentUser; @@ -36,6 +41,7 @@ public abstract class CommentViewModel : ReactiveObject, ICommentViewModel /// Initializes a new instance of the class. /// /// The comment service. + [ImportingConstructor] public CommentViewModel(ICommentService commentService) { Guard.ArgumentNotNull(commentService, nameof(commentService)); @@ -70,6 +76,9 @@ public CommentViewModel(ICommentService commentService) (ro, body) => !ro && !string.IsNullOrWhiteSpace(body))); AddErrorHandler(CommitEdit); + canCancel = this.WhenAnyValue(x => x.Id) + .Select(id => id != null) + .ToProperty(this, x => x.CanCancel); CancelEdit = ReactiveCommand.Create(DoCancelEdit, CommitEdit.IsExecuting.Select(x => !x)); AddErrorHandler(CancelEdit); @@ -140,6 +149,9 @@ public bool IsSubmitting protected set => this.RaiseAndSetIfChanged(ref isSubmitting, value); } + /// + public bool CanCancel => canCancel.Value; + /// public bool CanDelete => canDelete.Value; @@ -150,6 +162,9 @@ public DateTimeOffset CreatedAt private set => this.RaiseAndSetIfChanged(ref createdAt, value); } + /// + public string CommitCaption => commitCaption.Value; + /// public ICommentThreadViewModel Thread { @@ -175,14 +190,8 @@ public ICommentThreadViewModel Thread /// public ReactiveCommand Delete { get; } - /// - /// Initializes the view model with data. - /// - /// The thread that the comment is a part of. - /// The current user. - /// The comment model. May be null. - /// The comment edit state. - protected Task InitializeAsync( + /// + public Task InitializeAsync( ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, @@ -195,13 +204,15 @@ protected Task InitializeAsync( CurrentUser = new ActorViewModel(currentUser); Id = comment?.Id; DatabaseId = comment?.DatabaseId ?? 0; - PullRequestId = comment?.PullRequestId ?? 0; + PullRequestId = (comment as PullRequestReviewCommentModel)?.PullRequestId ?? 0; Body = comment?.Body; EditState = state; Author = comment != null ? new ActorViewModel(comment.Author) : CurrentUser; CreatedAt = comment?.CreatedAt ?? DateTimeOffset.MinValue; WebUrl = comment?.Url != null ? new Uri(comment.Url) : null; + commitCaption = GetCommitCaptionObservable().ToProperty(this, x => x.CommitCaption); + return Task.CompletedTask; } @@ -210,6 +221,12 @@ protected void AddErrorHandler(ReactiveCommand command) command.ThrownExceptions.Subscribe(x => ErrorMessage = x.Message); } + protected virtual IObservable GetCommitCaptionObservable() + { + return this.WhenAnyValue(x => x.Id) + .Select(x => x == null ? Resources.Comment : Resources.UpdateComment); + } + async Task DoDelete() { if (commentService.ConfirmCommentDelete()) diff --git a/src/GitHub.App/ViewModels/Documents/CommitListViewModel.cs b/src/GitHub.App/ViewModels/Documents/CommitListViewModel.cs new file mode 100644 index 0000000000..a72006ffd3 --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/CommitListViewModel.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using System.Text; + +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a list of commit summaries in a pull request timeline. + /// + [Export(typeof(ICommitListViewModel))] + public class CommitListViewModel : ViewModelBase, ICommitListViewModel + { + /// + /// Initializes a new instance of the class. + /// + /// The commits to display. + public CommitListViewModel(params ICommitSummaryViewModel[] commits) + { + if (commits.Length == 0) + { + throw new NotSupportedException("Cannot create a CommitListViewModel with 0 commits."); + } + + Commits = commits; + Author = Commits[0].Author; + AuthorCaption = BuildAuthorCaption(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The commits to display. + public CommitListViewModel(IEnumerable commits) + { + Commits = commits.ToList(); + + if (Commits.Count == 0) + { + throw new NotSupportedException("Cannot create a CommitListViewModel with 0 commits."); + } + + Author = Commits[0].Author; + AuthorCaption = BuildAuthorCaption(); + } + + /// + public IActorViewModel Author { get; } + + /// + public string AuthorCaption { get; } + + /// + public IReadOnlyList Commits { get; } + + string BuildAuthorCaption() + { + var result = new StringBuilder(); + + if (Commits.Any(x => x.Author.Login != Author.Login)) + { + result.Append(Resources.AndOthers); + result.Append(' '); + } + + result.Append(Resources.AddedSomeCommits); + return result.ToString(); + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/CommitSummaryViewModel.cs b/src/GitHub.App/ViewModels/Documents/CommitSummaryViewModel.cs new file mode 100644 index 0000000000..dc105488ed --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/CommitSummaryViewModel.cs @@ -0,0 +1,34 @@ +using GitHub.Models; + +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a one-line summary of a commit in a pull request timeline. + /// + public class CommitSummaryViewModel : ViewModelBase, ICommitSummaryViewModel + { + /// + /// Initializes a new instance of the class. + /// + /// The commit model. + public CommitSummaryViewModel(CommitModel commit) + { + AbbreviatedOid = commit.AbbreviatedOid; + Author = new ActorViewModel(commit.Author); + Header = commit.MessageHeadline; + Oid = commit.Oid; + } + + /// + public string AbbreviatedOid { get; private set; } + + /// + public IActorViewModel Author { get; private set; } + + /// + public string Header { get; private set; } + + /// + public string Oid { get; private set; } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/IIssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IIssueishCommentViewModel.cs new file mode 100644 index 0000000000..08bf9e1f1c --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/IIssueishCommentViewModel.cs @@ -0,0 +1,55 @@ +using System; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for comments on an issue or pull request. + /// + public interface IIssueishCommentViewModel : ICommentViewModel, IDisposable + { + /// + /// Gets a value indicating whether the comment will show a button for + /// . + /// + bool CanCloseOrReopen { get; } + + /// + /// Gets a a caption for the command. + /// + string CloseOrReopenCaption { get; } + + /// + /// Gets a command which when executed will close the issue or pull request if it is open, + /// or reopen it if it is closed. + /// + ReactiveCommand CloseOrReopen { get; } + + /// + /// Initializes the view model with data. + /// + /// The thread that the comment is a part of. + /// The current user. + /// The comment model. May be null. + /// + /// true if the comment is on a pull request, false if the comment is on an issue. + /// + /// + /// Whether the user can close or reopen the pull request from this comment. + /// + /// + /// An observable tracking whether the issue or pull request is open. Can be null if + /// is false. + /// + Task InitializeAsync( + IIssueishCommentThreadViewModel thread, + ActorModel currentUser, + CommentModel comment, + bool isPullRequest, + bool canCloseOrReopen, + IObservable isOpen = null); + } +} \ No newline at end of file diff --git a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs new file mode 100644 index 0000000000..184406eb1a --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs @@ -0,0 +1,103 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for comments on an issue or pull request. + /// + [Export(typeof(IIssueishCommentViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class IssueishCommentViewModel : CommentViewModel, IIssueishCommentViewModel + { + bool canCloseOrReopen; + ObservableAsPropertyHelper closeOrReopenCaption; + + /// + /// Initializes a new instance of the class. + /// + /// The comment service. + [ImportingConstructor] + public IssueishCommentViewModel(ICommentService commentService) + : base(commentService) + { + CloseOrReopen = ReactiveCommand.CreateFromTask( + DoCloseOrReopen, + this.WhenAnyValue(x => x.CanCloseOrReopen)); + AddErrorHandler(CloseOrReopen); + } + + /// + public bool CanCloseOrReopen + { + get => canCloseOrReopen; + private set => this.RaiseAndSetIfChanged(ref canCloseOrReopen, value); + } + + /// + public string CloseOrReopenCaption => closeOrReopenCaption?.Value; + + /// + public ReactiveCommand CloseOrReopen { get; } + + /// + public async Task InitializeAsync( + IIssueishCommentThreadViewModel thread, + ActorModel currentUser, + CommentModel comment, + bool isPullRequest, + bool canCloseOrReopen, + IObservable isOpen = null) + { + await base.InitializeAsync( + thread, + currentUser, + comment, + comment == null ? CommentEditState.Editing : CommentEditState.None) + .ConfigureAwait(true); + + CanCloseOrReopen = canCloseOrReopen; + closeOrReopenCaption?.Dispose(); + + if (canCloseOrReopen && isOpen != null) + { + closeOrReopenCaption = + this.WhenAnyValue(x => x.Body) + .CombineLatest(isOpen, (body, open) => GetCloseOrReopenCaption(isPullRequest, open, body)) + .ToProperty(this, x => x.CloseOrReopenCaption); + } + } + + public void Dispose() => closeOrReopenCaption?.Dispose(); + + async Task DoCloseOrReopen() + { + await ((IIssueishCommentThreadViewModel)Thread).CloseOrReopen(this).ConfigureAwait(true); + } + + static string GetCloseOrReopenCaption(bool isPullRequest, bool isOpen, string body) + { + if (string.IsNullOrEmpty(body)) + { + if (isPullRequest) + { + return isOpen ? Resources.ClosePullRequest : Resources.ReopenPullRequest; + } + else + { + return isOpen ? Resources.CloseIssue: Resources.ReopenIssue; + } + } + else + { + return isOpen ? Resources.CloseAndComment : Resources.ReopenAndComment; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/IssueishPaneViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishPaneViewModel.cs new file mode 100644 index 0000000000..9e7650c19d --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/IssueishPaneViewModel.cs @@ -0,0 +1,86 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + [Export(typeof(IIssueishPaneViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class IssueishPaneViewModel : ViewModelBase, IIssueishPaneViewModel + { + readonly IViewViewModelFactory factory; + readonly IPullRequestSessionManager sessionManager; + IViewModel content; + string paneCaption; + + [ImportingConstructor] + public IssueishPaneViewModel( + IViewViewModelFactory factory, + IPullRequestSessionManager sessionManager) + { + Guard.ArgumentNotNull(factory, nameof(factory)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + + this.factory = factory; + this.sessionManager = sessionManager; + } + + public IViewModel Content + { + get => content; + private set => this.RaiseAndSetIfChanged(ref content, value); + } + + public bool IsInitialized => content != null; + + public string PaneCaption + { + get => paneCaption; + private set => this.RaiseAndSetIfChanged(ref paneCaption, value); + } + + public Task InitializeAsync(IServiceProvider paneServiceProvider) + { + return Task.CompletedTask; + } + + public async Task Load(IConnection connection, string owner, string name, int number) + { + Content = new SpinnerViewModel(); + PaneCaption = "#" + number; + + // TODO: We will eventually support loading issues here as well. + try + { + var session = await sessionManager.GetSession(owner, name, number).ConfigureAwait(true); + var vm = factory.CreateViewModel(); + + var repository = new RemoteRepositoryModel( + 0, + name, + session.LocalRepository.CloneUrl.WithOwner(session.PullRequest.HeadRepositoryOwner), + false, + false, + null, + null); + + await vm.InitializeAsync( + repository, + session.LocalRepository, + session.User, + session.PullRequest).ConfigureAwait(true); + Content = vm; + PaneCaption += " " + vm.Title; + } + catch (Exception ex) + { + // TODO: Show exception. + } + } + } +} diff --git a/src/GitHub.App/ViewModels/Documents/PullRequestPageViewModel.cs b/src/GitHub.App/ViewModels/Documents/PullRequestPageViewModel.cs new file mode 100644 index 0000000000..879779792d --- /dev/null +++ b/src/GitHub.App/ViewModels/Documents/PullRequestPageViewModel.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Factories; +using GitHub.Models; +using GitHub.Primitives; +using GitHub.Services; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for displaying a pull request in a document window. + /// + [Export(typeof(IPullRequestPageViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class PullRequestPageViewModel : PullRequestViewModelBase, IPullRequestPageViewModel, IIssueishCommentThreadViewModel + { + readonly IViewViewModelFactory factory; + readonly IPullRequestService service; + readonly IPullRequestSessionManager sessionManager; + readonly ITeamExplorerServices teServices; + readonly IVisualStudioBrowser visualStudioBrowser; + readonly IUsageTracker usageTracker; + ActorModel currentUserModel; + ReactiveList timeline = new ReactiveList(); + + /// + /// Initializes a new instance of the class. + /// + /// The view model factory. + [ImportingConstructor] + public PullRequestPageViewModel( + IViewViewModelFactory factory, + IPullRequestService service, + IPullRequestSessionManager sessionManager, + ITeamExplorerServices teServices, + IVisualStudioBrowser visualStudioBrowser, + IUsageTracker usageTracker) + { + Guard.ArgumentNotNull(factory, nameof(factory)); + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); + Guard.ArgumentNotNull(visualStudioBrowser, nameof(visualStudioBrowser)); + Guard.ArgumentNotNull(teServices, nameof(teServices)); + + this.factory = factory; + this.service = service; + this.sessionManager = sessionManager; + this.teServices = teServices; + this.visualStudioBrowser = visualStudioBrowser; + this.usageTracker = usageTracker; + + timeline.ItemsRemoved.Subscribe(TimelineItemRemoved); + + ShowCommit = ReactiveCommand.CreateFromTask(DoShowCommit); + OpenOnGitHub = ReactiveCommand.Create(DoOpenOnGitHub); + } + + /// + public IActorViewModel CurrentUser { get; private set; } + + /// + public int CommitCount { get; private set; } + + /// + public IReadOnlyList Timeline => timeline; + + /// + public ReactiveCommand ShowCommit { get; } + + /// + public async Task InitializeAsync( + RemoteRepositoryModel repository, + LocalRepositoryModel localRepository, + ActorModel currentUser, + PullRequestDetailModel model) + { + await base.InitializeAsync(repository, localRepository, model).ConfigureAwait(true); + + timeline.Clear(); + CommitCount = 0; + currentUserModel = currentUser; + CurrentUser = new ActorViewModel(currentUser); + + var commits = new List(); + + foreach (var i in model.Timeline) + { + if (!(i is CommitModel) && commits.Count > 0) + { + timeline.Add(new CommitListViewModel(commits)); + commits.Clear(); + } + + switch (i) + { + case CommitModel commit: + commits.Add(new CommitSummaryViewModel(commit)); + ++CommitCount; + break; + case CommentModel comment: + await AddComment(comment).ConfigureAwait(true); + break; + } + } + + if (commits.Count > 0) + { + timeline.Add(new CommitListViewModel(commits)); + } + + await AddPlaceholder().ConfigureAwait(true); + await usageTracker.IncrementCounter(x => x.NumberOfPRConversationsOpened); + } + + /// + public async Task CloseOrReopen(ICommentViewModel comment) + { + var address = HostAddress.Create(Repository.CloneUrl); + + if (State == PullRequestState.Open) + { + await service.CloseIssueish( + address, + Repository.Owner, + Repository.Name, + Number).ConfigureAwait(true); + State = PullRequestState.Closed; + } + else + { + await service.ReopenIssueish( + address, + Repository.Owner, + Repository.Name, + Number).ConfigureAwait(true); + State = PullRequestState.Open; + } + } + + /// + public async Task PostComment(ICommentViewModel comment) + { + var address = HostAddress.Create(Repository.CloneUrl); + var result = await service.PostComment(address, Id, comment.Body).ConfigureAwait(true); + timeline.Remove(comment); + await AddComment(result).ConfigureAwait(true); + await AddPlaceholder().ConfigureAwait(true); + } + + public async Task DeleteComment(ICommentViewModel comment) + { + await service.DeleteComment( + HostAddress.Create(Repository.CloneUrl), + Repository.Owner, + Repository.Name, + comment.DatabaseId).ConfigureAwait(true); + timeline.Remove(comment); + } + + public async Task EditComment(ICommentViewModel comment) + { + await service.EditComment( + HostAddress.Create(Repository.CloneUrl), + Repository.Owner, + Repository.Name, + comment.DatabaseId, + comment.Body).ConfigureAwait(false); + } + + async Task AddComment(CommentModel comment) + { + var vm = factory.CreateViewModel(); + await vm.InitializeAsync( + this, + currentUserModel, + comment, + true, + false).ConfigureAwait(true); + timeline.Add(vm); + } + + async Task AddPlaceholder() + { + var placeholder = factory.CreateViewModel(); + await placeholder.InitializeAsync( + this, + currentUserModel, + null, + true, + true, + this.WhenAnyValue(x => x.State, x => x == PullRequestState.Open)).ConfigureAwait(true); + timeline.Add(placeholder); + } + + async Task DoShowCommit(string oid) + { + await service.FetchCommit(LocalRepository, Repository, oid).ConfigureAwait(true); + teServices.ShowCommitDetails(oid); + } + + void DoOpenOnGitHub() + { + visualStudioBrowser.OpenUrl(WebUrl); + } + + void TimelineItemRemoved(IViewModel item) + { + (item as IDisposable)?.Dispose(); + } + } +} diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs index d949f0bfd9..29947ba8b1 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs @@ -24,6 +24,7 @@ using Serilog; using static System.FormattableString; using ReactiveCommand = ReactiveUI.ReactiveCommand; +using GitHub.Primitives; namespace GitHub.ViewModels.GitHubPane { @@ -42,6 +43,7 @@ public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullReq readonly ISyncSubmodulesCommand syncSubmodulesCommand; readonly IViewViewModelFactory viewViewModelFactory; readonly IGitService gitService; + readonly IOpenIssueishDocumentCommand openDocumentCommand; IModelService modelService; PullRequestDetailModel model; @@ -82,7 +84,8 @@ public PullRequestDetailViewModel( IPullRequestFilesViewModel files, ISyncSubmodulesCommand syncSubmodulesCommand, IViewViewModelFactory viewViewModelFactory, - IGitService gitService) + IGitService gitService, + IOpenIssueishDocumentCommand openDocumentCommand) { Guard.ArgumentNotNull(pullRequestsService, nameof(pullRequestsService)); Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); @@ -92,6 +95,7 @@ public PullRequestDetailViewModel( Guard.ArgumentNotNull(syncSubmodulesCommand, nameof(syncSubmodulesCommand)); Guard.ArgumentNotNull(viewViewModelFactory, nameof(viewViewModelFactory)); Guard.ArgumentNotNull(gitService, nameof(gitService)); + Guard.ArgumentNotNull(openDocumentCommand, nameof(openDocumentCommand)); this.pullRequestsService = pullRequestsService; this.sessionManager = sessionManager; @@ -101,6 +105,7 @@ public PullRequestDetailViewModel( this.syncSubmodulesCommand = syncSubmodulesCommand; this.viewViewModelFactory = viewViewModelFactory; this.gitService = gitService; + this.openDocumentCommand = openDocumentCommand; Files = files; @@ -134,6 +139,8 @@ public PullRequestDetailViewModel( SyncSubmodules.Subscribe(_ => Refresh().ToObservable()); SubscribeOperationError(SyncSubmodules); + OpenConversation = ReactiveCommand.Create(DoOpenConversation); + OpenOnGitHub = ReactiveCommand.Create(DoOpenDetailsUrl); ShowReview = ReactiveCommand.Create(DoShowReview); @@ -144,11 +151,6 @@ public PullRequestDetailViewModel( [Import(AllowDefault = true)] private IStaticReviewFileMapManager StaticReviewFileMapManager { get; set; } - private void DoOpenDetailsUrl() - { - usageTracker.IncrementCounter(measuresModel => measuresModel.NumberOfPRDetailsOpenInGitHub).Forget(); - } - /// public PullRequestDetailModel Model { @@ -273,6 +275,9 @@ public Uri WebUrl /// public ReactiveCommand SyncSubmodules { get; } + /// + public ReactiveCommand OpenConversation { get; } + /// public ReactiveCommand OpenOnGitHub { get; } @@ -650,6 +655,21 @@ async Task DoSyncSubmodules() } } + void DoOpenConversation() + { + var p = new OpenIssueishParams( + HostAddress.Create(LocalRepository.CloneUrl), + RemoteRepositoryOwner, + LocalRepository.Name, + Number); + openDocumentCommand.Execute(p); + } + + void DoOpenDetailsUrl() + { + usageTracker.IncrementCounter(measuresModel => measuresModel.NumberOfPRDetailsOpenInGitHub).Forget(); + } + void DoShowReview(IPullRequestReviewSummaryViewModel review) { if (review.State == PullRequestReviewState.Pending) diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs index 7468367837..d81467e938 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestListViewModel.cs @@ -6,6 +6,7 @@ using System.Reactive.Linq; using System.Threading.Tasks; using GitHub.Collections; +using GitHub.Commands; using GitHub.Extensions; using GitHub.Models; using GitHub.Primitives; @@ -125,18 +126,18 @@ protected override IIssueListItemViewModelBase CreateViewModel(PullRequestListIt protected override async Task> LoadPage(string after) { - PullRequestStateEnum[] states; + PullRequestState[] states; switch (owner.SelectedState) { case "Open": - states = new[] { PullRequestStateEnum.Open }; + states = new[] { PullRequestState.Open }; break; case "Closed": - states = new[] { PullRequestStateEnum.Closed, PullRequestStateEnum.Merged }; + states = new[] { PullRequestState.Closed, PullRequestState.Merged }; break; default: - states = new[] { PullRequestStateEnum.Open, PullRequestStateEnum.Closed, PullRequestStateEnum.Merged }; + states = new[] { PullRequestState.Open, PullRequestState.Closed, PullRequestState.Merged }; break; } diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs index 718a906f1a..2f1db0020f 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestUserReviewsViewModel.cs @@ -144,7 +144,6 @@ async Task Load(PullRequestDetailModel pullRequest) try { - await Task.Delay(0); PullRequestTitle = pullRequest.Title; var reviews = new List(); diff --git a/src/GitHub.App/ViewModels/IssueishViewModel.cs b/src/GitHub.App/ViewModels/IssueishViewModel.cs new file mode 100644 index 0000000000..ba79091eda --- /dev/null +++ b/src/GitHub.App/ViewModels/IssueishViewModel.cs @@ -0,0 +1,85 @@ +using System; +using System.ComponentModel.Composition; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Logging; +using GitHub.Models; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels +{ + /// + /// Base class for issue and pull request view models. + /// + public class IssueishViewModel : ViewModelBase, IIssueishViewModel + { + static readonly ILogger log = LogManager.ForContext(); + + IActorViewModel author; + string body; + string title; + Uri webUrl; + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public IssueishViewModel() + { + } + + /// + public RemoteRepositoryModel Repository { get; private set; } + + /// + public string Id { get; private set; } + + /// + public int Number { get; private set; } + + /// + public IActorViewModel Author + { + get => author; + private set => this.RaiseAndSetIfChanged(ref author, value); + } + + /// + public string Body + { + get => body; + protected set => this.RaiseAndSetIfChanged(ref body, value); + } + + /// + public string Title + { + get => title; + protected set => this.RaiseAndSetIfChanged(ref title, value); + } + + /// + public Uri WebUrl + { + get { return webUrl; } + protected set { this.RaiseAndSetIfChanged(ref webUrl, value); } + } + + /// + public ReactiveCommand OpenOnGitHub { get; protected set; } + + protected Task InitializeAsync( + RemoteRepositoryModel repository, + IssueishDetailModel model) + { + Repository = repository; + Id = model.Id; + Author = new ActorViewModel(model.Author); + Body = model.Body; + Number = model.Number; + Title = model.Title; + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs index c821754f8b..91a9f24f14 100644 --- a/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs @@ -23,6 +23,7 @@ namespace GitHub.ViewModels [PartCreationPolicy(CreationPolicy.NonShared)] public class PullRequestReviewCommentThreadViewModel : CommentThreadViewModel, IPullRequestReviewCommentThreadViewModel { + readonly ReactiveList comments = new ReactiveList(); readonly IViewViewModelFactory factory; readonly ObservableAsPropertyHelper needsPush; IPullRequestSessionFile file; @@ -50,6 +51,9 @@ public PullRequestReviewCommentThreadViewModel( .ToProperty(this, x => x.NeedsPush); } + /// + public IReactiveList Comments => comments; + /// public IPullRequestSession Session { get; private set; } @@ -76,7 +80,11 @@ public bool IsNewThread public bool NeedsPush => needsPush.Value; /// - public async Task InitializeAsync(IPullRequestSession session, + IReadOnlyReactiveList IPullRequestReviewCommentThreadViewModel.Comments => comments; + + /// + public async Task InitializeAsync( + IPullRequestSession session, IPullRequestSessionFile file, IInlineCommentThreadModel thread, bool addPlaceholder) @@ -121,7 +129,8 @@ await vm.InitializeAsPlaceholderAsync( vm.Body = draft.Body; } - AddPlaceholder(vm); + InitializePlaceholder(vm); + comments.Add(vm); } } @@ -154,7 +163,8 @@ public async Task InitializeNewAsync( vm.Body = draft.Body; } - AddPlaceholder(vm); + InitializePlaceholder(vm); + comments.Add(vm); } public override async Task PostComment(ICommentViewModel comment) diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs index dc92301ed0..1d47330c88 100644 --- a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs @@ -19,7 +19,6 @@ namespace GitHub.ViewModels public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestReviewCommentViewModel { readonly ObservableAsPropertyHelper canStartReview; - readonly ObservableAsPropertyHelper commitCaption; IPullRequestSession session; bool isPending; @@ -31,19 +30,12 @@ public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestR public PullRequestReviewCommentViewModel(ICommentService commentService) : base(commentService) { - var pendingAndIsNew = this.WhenAnyValue( + canStartReview = this.WhenAnyValue( x => x.IsPending, x => x.Id, - (isPending, id) => (isPending, isNewComment: id == null)); - - canStartReview = pendingAndIsNew - .Select(arg => !arg.isPending && arg.isNewComment) + (isPending, id) => !isPending && id == null) .ToProperty(this, x => x.CanStartReview); - commitCaption = pendingAndIsNew - .Select(arg => !arg.isNewComment ? Resources.UpdateComment : arg.isPending ? Resources.AddReviewComment : Resources.AddSingleComment) - .ToProperty(this, x => x.CommitCaption); - StartReview = ReactiveCommand.CreateFromTask(DoStartReview, CommitEdit.CanExecute); AddErrorHandler(StartReview); } @@ -84,9 +76,6 @@ await InitializeAsync( /// public bool CanStartReview => canStartReview.Value; - /// - public string CommitCaption => commitCaption.Value; - /// public bool IsPending { @@ -97,6 +86,16 @@ public bool IsPending /// public ReactiveCommand StartReview { get; } + protected override IObservable GetCommitCaptionObservable() + { + return this.WhenAnyValue( + x => x.IsPending, + x => x.Id, + (pending, id) => id != null ? + Resources.UpdateComment : + pending ? Resources.AddReviewComment : Resources.AddSingleComment); + } + async Task DoStartReview() { IsSubmitting = true; diff --git a/src/GitHub.App/ViewModels/PullRequestViewModelBase.cs b/src/GitHub.App/ViewModels/PullRequestViewModelBase.cs new file mode 100644 index 0000000000..d831effced --- /dev/null +++ b/src/GitHub.App/ViewModels/PullRequestViewModelBase.cs @@ -0,0 +1,78 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using ReactiveUI; +using Serilog; + +namespace GitHub.ViewModels +{ + /// + /// Base class for pull request view models. + /// + public class PullRequestViewModelBase : IssueishViewModel, IPullRequestViewModelBase + { + static readonly ILogger log = LogManager.ForContext(); + PullRequestState state; + string sourceBranchDisplayName; + string targetBranchDisplayName; + + /// + /// Initializes a new instance of the class. + /// + [ImportingConstructor] + public PullRequestViewModelBase() + { + } + + /// + public LocalRepositoryModel LocalRepository { get; private set; } + + public PullRequestState State + { + get => state; + protected set => this.RaiseAndSetIfChanged(ref state, value); + } + + public string SourceBranchDisplayName + { + get => sourceBranchDisplayName; + private set => this.RaiseAndSetIfChanged(ref sourceBranchDisplayName, value); + } + + public string TargetBranchDisplayName + { + get => targetBranchDisplayName; + private set => this.RaiseAndSetIfChanged(ref targetBranchDisplayName, value); + } + + protected virtual async Task InitializeAsync( + RemoteRepositoryModel repository, + LocalRepositoryModel localRepository, + PullRequestDetailModel model) + { + await base.InitializeAsync(repository, model).ConfigureAwait(true); + + var fork = model.BaseRepositoryOwner != model.HeadRepositoryOwner; + LocalRepository = localRepository; + State = model.State; + SourceBranchDisplayName = GetBranchDisplayName(fork, model.HeadRepositoryOwner, model.HeadRefName); + TargetBranchDisplayName = GetBranchDisplayName(fork, model.BaseRepositoryOwner, model.BaseRefName); + WebUrl = localRepository.CloneUrl.ToRepositoryUrl().Append("pull/" + Number); + } + + static string GetBranchDisplayName(bool isFromFork, string owner, string label) + { + if (owner != null) + { + return isFromFork ? owner + ':' + label : label; + } + else + { + return Resources.InvalidBranchName; + } + } + } +} diff --git a/src/GitHub.App/ViewModels/SpinnerViewModel.cs b/src/GitHub.App/ViewModels/SpinnerViewModel.cs new file mode 100644 index 0000000000..3395ca8789 --- /dev/null +++ b/src/GitHub.App/ViewModels/SpinnerViewModel.cs @@ -0,0 +1,14 @@ +using System; +using System.ComponentModel.Composition; + +namespace GitHub.ViewModels +{ + /// + /// View model which displays a spinner. + /// + [Export(typeof(ISpinnerViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public class SpinnerViewModel : ViewModelBase, ISpinnerViewModel + { + } +} diff --git a/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs b/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs index 838dda5b8e..dd33e6f8a4 100644 --- a/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs +++ b/src/GitHub.Exports.Reactive/Models/RemoteRepositoryModel.cs @@ -28,7 +28,6 @@ public RemoteRepositoryModel(long id, string name, UriString cloneUrl, bool isPr : base(name, cloneUrl) { Guard.ArgumentNotEmptyString(name, nameof(name)); - Guard.ArgumentNotNull(ownerAccount, nameof(ownerAccount)); Id = id; OwnerAccount = ownerAccount; diff --git a/src/GitHub.Exports.Reactive/Services/IGitClient.cs b/src/GitHub.Exports.Reactive/Services/IGitClient.cs index 81fbbafebe..8579ab683a 100644 --- a/src/GitHub.Exports.Reactive/Services/IGitClient.cs +++ b/src/GitHub.Exports.Reactive/Services/IGitClient.cs @@ -64,6 +64,14 @@ public interface IGitClient /// Task Checkout(IRepository repository, string branchName); + /// + /// Checks if a commit exists a the repository. + /// + /// The repository. + /// The SHA of the commit. + /// + Task CommitExists(IRepository repository, string sha); + /// /// Creates a new branch. /// diff --git a/src/GitHub.Exports.Reactive/Services/IIssueishService.cs b/src/GitHub.Exports.Reactive/Services/IIssueishService.cs new file mode 100644 index 0000000000..e4a3ba55e6 --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/IIssueishService.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Primitives; + +namespace GitHub.Services +{ + /// + /// Services for issues and pull requests. + /// + public interface IIssueishService + { + /// + /// Closes an issue or pull request. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + Task CloseIssueish(HostAddress address, string owner, string repository, int number); + + /// + /// Reopens an issue or pull request. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + Task ReopenIssueish(HostAddress address, string owner, string repository, int number); + + /// + /// Posts an issue or pull request comment. + /// + /// The address of the server. + /// The GraphQL ID of the issue or pull request. + /// The comment body. + /// The model for the comment that was added. + Task PostComment( + HostAddress address, + string issueishId, + string body); + + /// + /// Deletes an issue or pull request comment. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The database ID of the comment. + Task DeleteComment( + HostAddress address, + string owner, + string repository, + int commentId); + + /// + /// Edits an issue or pull request comment. + /// + /// The address of the server. + /// The repository owner. + /// The repository name. + /// The database ID of the comment. + /// The new comment body. + Task EditComment( + HostAddress address, + string owner, + string repository, + int commentId, + string body); + } +} diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs index 29e8e1d5f4..9513f0a177 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestService.cs @@ -9,7 +9,7 @@ namespace GitHub.Services { - public interface IPullRequestService + public interface IPullRequestService : IIssueishService { /// /// Reads a page of pull request items. @@ -25,7 +25,7 @@ Task> ReadPullRequests( string owner, string name, string after, - PullRequestStateEnum[] states); + PullRequestState[] states); /// /// Reads a page of users that can be assigned to pull requests. @@ -69,6 +69,15 @@ IObservable CreatePullRequest(IModelService modelService, /// IObservable Checkout(LocalRepositoryModel repository, PullRequestDetailModel pullRequest, string localBranchName); + /// + /// Checks if a commit is available and if not tries to fetch the commit. + /// + /// The local repository. + /// The remote repository. + /// The SHA of the commit. + /// True if the commit was found, otherwise false. + Task FetchCommit(LocalRepositoryModel localRepository, RepositoryModel remoteRepository, string sha); + /// /// Carries out a pull on the current branch. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitListViewModel.cs new file mode 100644 index 0000000000..83dbdf4def --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitListViewModel.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a list of commit summaries in a pull request timeline. + /// + public interface ICommitListViewModel : IViewModel + { + /// + /// Gets the first author of the commits in the list. + /// + IActorViewModel Author { get; } + + /// + /// Gets a string to display next to the author in the view. + /// + string AuthorCaption { get; } + + /// + /// Gets the commits. + /// + IReadOnlyList Commits { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitSummaryViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitSummaryViewModel.cs new file mode 100644 index 0000000000..b6a645e558 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/ICommitSummaryViewModel.cs @@ -0,0 +1,28 @@ +namespace GitHub.ViewModels.Documents +{ + /// + /// Displays a one-line summary of a commit in a pull request timeline. + /// + public interface ICommitSummaryViewModel : IViewModel + { + /// + /// Gets the abbreviated OID (SHA) of the commit. + /// + string AbbreviatedOid { get; } + + /// + /// Gets the commit author. + /// + IActorViewModel Author { get; } + + /// + /// Gets the commit message header. + /// + string Header { get; } + + /// + /// Gets the OID (SHA) of the commit. + /// + string Oid { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/IIssueishCommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/IIssueishCommentThreadViewModel.cs new file mode 100644 index 0000000000..d3cd6dd86e --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/IIssueishCommentThreadViewModel.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.ViewModels.Documents +{ + /// + /// A thread of issue or pull request comments. + /// + public interface IIssueishCommentThreadViewModel : ICommentThreadViewModel + { + /// + /// Called by a comment in the thread to close the issue or pull request. + /// + /// The comment requesting the close. + Task CloseOrReopen(ICommentViewModel comment); + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/Documents/IPullRequestPageViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/Documents/IPullRequestPageViewModel.cs new file mode 100644 index 0000000000..5f311fcfa3 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/Documents/IPullRequestPageViewModel.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for displaying a pull request in a document window. + /// + public interface IPullRequestPageViewModel : IPullRequestViewModelBase + { + /// + /// Gets the number of commits in the pull request. + /// + int CommitCount { get; } + + /// + /// Gets the pull request's timeline. + /// + IReadOnlyList Timeline { get; } + + /// + /// Gets a command that will open a commit in Team Explorer. + /// + ReactiveCommand ShowCommit { get; } + + /// + /// Initializes the view model with data. + /// + /// The repository to which the pull request belongs. + /// The local repository. + /// The currently logged in user. + /// The pull request model. + Task InitializeAsync( + RemoteRepositoryModel repository, + LocalRepositoryModel localRepository, + ActorModel currentUser, + PullRequestDetailModel model); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs index 52384df726..0e4616fb67 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs @@ -171,6 +171,11 @@ public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowse /// ReactiveCommand SyncSubmodules { get; } + /// + /// Gets a command that opens the pull request conversation in a document pane. + /// + ReactiveCommand OpenConversation { get; } + /// /// Gets a command that opens the pull request on GitHub. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs index e347329812..802a0083ad 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestListViewModel.cs @@ -15,7 +15,7 @@ public interface IPullRequestListViewModel : IIssueListViewModelBase, IOpenInBro ReactiveCommand CreatePullRequest { get; } /// - /// Gets a command that opens pull request item on GitHub. + /// Gets a command that opens the pull request item on GitHub. /// ReactiveCommand OpenItemInBrowser { get; } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs index 31c24d15a5..5c520519cd 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentThreadViewModel.cs @@ -10,12 +10,7 @@ namespace GitHub.ViewModels public interface ICommentThreadViewModel : IViewModel { /// - /// Gets the comments in the thread. - /// - IReadOnlyReactiveList Comments { get; } - - /// - /// Gets the current user under whos account new comments will be created. + /// Gets the current user under whose account new comments will be created. /// IActorViewModel CurrentUser { get; } diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs index 5914b0ddbd..ce35d8a3ff 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs @@ -11,8 +11,8 @@ public enum CommentEditState Placeholder, } - /// - /// View model for an issue or pull request comment. + /// + /// View model for an issue, pull request or pull request review comment. /// public interface ICommentViewModel : IViewModel { @@ -63,7 +63,12 @@ public interface ICommentViewModel : IViewModel bool IsSubmitting { get; } /// - /// Gets a value indicating whether the comment can be edited or deleted by the current user + /// Gets a value indicating whether the comment edit state can be canceled. + /// + bool CanCancel { get; } + + /// + /// Gets a value indicating whether the comment can be edited or deleted by the current user. /// bool CanDelete { get; } @@ -72,6 +77,14 @@ public interface ICommentViewModel : IViewModel /// DateTimeOffset CreatedAt { get; } + /// + /// Gets the caption for the "Commit" button. + /// + /// + /// This will be "Comment" when editing a new comment and "Update" when editing an existing comment. + /// + string CommitCaption { get; } + /// /// Gets the thread that the comment is a part of. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/IIssueishViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IIssueishViewModel.cs new file mode 100644 index 0000000000..fed3456d09 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IIssueishViewModel.cs @@ -0,0 +1,53 @@ +using System; +using System.Reactive; +using GitHub.Models; +using ReactiveUI; + +namespace GitHub.ViewModels +{ + /// + /// Base interface for issue and pull request view models. + /// + public interface IIssueishViewModel : IViewModel + { + /// + /// Gets the GraphQL ID for the issue or pull request. + /// + string Id { get; } + + /// + /// Gets the issue or pull request author. + /// + IActorViewModel Author { get; } + + /// + /// Gets the issue or pull request body. + /// + string Body { get; } + + /// + /// Gets the issue or pull request number. + /// + int Number { get; } + + /// + /// Gets the repository that the issue or pull request comes from. + /// + RemoteRepositoryModel Repository { get; } + + /// + /// Gets the issue or pull request title. + /// + string Title { get; } + + /// + /// Gets the URL of the issue or pull request. + /// + Uri WebUrl { get; } + + /// + /// Gets a command which opens the issue or pull request in a browser. + /// + ReactiveCommand OpenOnGitHub { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs index 0afeb7ba12..a1bae3db1b 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentThreadViewModel.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using GitHub.Models; using GitHub.Services; +using ReactiveUI; namespace GitHub.ViewModels { @@ -10,6 +11,11 @@ namespace GitHub.ViewModels /// public interface IPullRequestReviewCommentThreadViewModel : ICommentThreadViewModel { + /// + /// Gets the comments in the thread. + /// + IReadOnlyReactiveList Comments { get; } + /// /// Gets the current pull request review session. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs index b05ab5c0f7..8212fdba3e 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestReviewCommentViewModel.cs @@ -16,15 +16,6 @@ public interface IPullRequestReviewCommentViewModel : ICommentViewModel /// bool CanStartReview { get; } - /// - /// Gets the caption for the "Commit" button. - /// - /// - /// This will be "Add a single comment" when not in review mode and "Add review comment" - /// when in review mode. - /// - string CommitCaption { get; } - /// /// Gets a value indicating whether this comment is part of a pending pull request review. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/IPullRequestViewModelBase.cs b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestViewModelBase.cs new file mode 100644 index 0000000000..ae197e1c61 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/IPullRequestViewModelBase.cs @@ -0,0 +1,30 @@ +using GitHub.Models; + +namespace GitHub.ViewModels +{ + /// + /// Base class for pull request view models. + /// + public interface IPullRequestViewModelBase : IIssueishViewModel + { + /// + /// Gets the local repository. + /// + LocalRepositoryModel LocalRepository { get; } + + /// + /// Gets the pull request state. + /// + PullRequestState State { get; } + + /// + /// Gets a the pull request's source (head) branch display. + /// + string SourceBranchDisplayName { get; } + + /// + /// Gets a the pull request's target (base) branch display. + /// + string TargetBranchDisplayName { get; } + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/IOpenIssueishDocumentCommand.cs b/src/GitHub.Exports/Commands/IOpenIssueishDocumentCommand.cs new file mode 100644 index 0000000000..4f767cc503 --- /dev/null +++ b/src/GitHub.Exports/Commands/IOpenIssueishDocumentCommand.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.Commands +{ + /// + /// Opens an issue or pull request in a new document window. + /// + public interface IOpenIssueishDocumentCommand : IVsCommand + { + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/Commands/OpenIssueishParams.cs b/src/GitHub.Exports/Commands/OpenIssueishParams.cs new file mode 100644 index 0000000000..2c3d7e6ea2 --- /dev/null +++ b/src/GitHub.Exports/Commands/OpenIssueishParams.cs @@ -0,0 +1,23 @@ +using GitHub.Primitives; + +namespace GitHub.Commands +{ + public class OpenIssueishParams + { + public OpenIssueishParams( + HostAddress address, + string owner, + string repository, + int number) + { + Address = address; + Owner = owner; + Repository = repository; + Number = number; + } + public HostAddress Address { get; } + public string Owner { get; } + public string Repository { get; } + public int Number { get; } + } +} diff --git a/src/GitHub.Exports/Models/CommentModel.cs b/src/GitHub.Exports/Models/CommentModel.cs index 2b601714dd..902bf5263a 100644 --- a/src/GitHub.Exports/Models/CommentModel.cs +++ b/src/GitHub.Exports/Models/CommentModel.cs @@ -17,14 +17,6 @@ public class CommentModel /// public int DatabaseId { get; set; } - /// - /// Gets the PullRequestId of the comment. - /// - public int PullRequestId { get; set; } - // The GraphQL Api does not allow for deleting of pull request comments. - // REST Api must be used, and PullRequestId is needed to reload the pull request. - // This field should be removed with better GraphQL support. - /// /// Gets the author of the comment. /// diff --git a/src/GitHub.Exports/Models/CommitModel.cs b/src/GitHub.Exports/Models/CommitModel.cs new file mode 100644 index 0000000000..370c38cb1f --- /dev/null +++ b/src/GitHub.Exports/Models/CommitModel.cs @@ -0,0 +1,30 @@ +using System; + +namespace GitHub.Models +{ + /// + /// Holds the details of a commit. + /// + public class CommitModel + { + /// + /// Gets or sets the author of the commit. + /// + public ActorModel Author { get; set; } + + /// + /// Gets or sets the abbreviated git object ID for the commit. + /// + public string AbbreviatedOid { get; set; } + + /// + /// Gets or sets the commit headline. + /// + public string MessageHeadline { get; set; } + + /// + /// Gets or sets the git object ID for the commit. + /// + public string Oid { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/IPullRequestModel.cs b/src/GitHub.Exports/Models/IPullRequestModel.cs index 3a8df4952f..58672e3eec 100644 --- a/src/GitHub.Exports/Models/IPullRequestModel.cs +++ b/src/GitHub.Exports/Models/IPullRequestModel.cs @@ -4,9 +4,7 @@ namespace GitHub.Models { - /// TODO: A PullRequestState class already exists hence the ugly naming of this. - /// Merge the two when the maintainer workflow has been merged to master. - public enum PullRequestStateEnum + public enum PullRequestState { Open, Closed, @@ -27,7 +25,7 @@ public interface IPullRequestModel : ICopyable, { int Number { get; } string Title { get; } - PullRequestStateEnum State { get; } + PullRequestState State { get; } int CommentCount { get; } int CommitCount { get; } bool IsOpen { get; } diff --git a/src/GitHub.Exports/Models/IssueishDetailModel.cs b/src/GitHub.Exports/Models/IssueishDetailModel.cs new file mode 100644 index 0000000000..39ac56562d --- /dev/null +++ b/src/GitHub.Exports/Models/IssueishDetailModel.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace GitHub.Models +{ + /// + /// Base class for issue and pull request detail models. + /// + public class IssueishDetailModel + { + /// + /// Gets or sets the GraphQL ID of the issue or pull request. + /// + public string Id { get; set; } + + /// + /// Gets or sets the issue or pull request number. + /// + public int Number { get; set; } + + /// + /// Gets or sets the issue or pull request author. + /// + public ActorModel Author { get; set; } + + /// + /// Gets or sets the issue or pull request title. + /// + public string Title { get; set; } + + /// + /// Gets or sets the issue or pull request body. + /// + public string Body { get; set; } + + /// + /// Gets or sets the date/time at which the issue or pull request was last updated. + /// + public DateTimeOffset UpdatedAt { get; set; } + + /// + /// Gets or sets the comments on the issue or pull request. + /// + public IReadOnlyList Comments { get; set; } + + /// + /// Gets or sets the number of comments on the issue or pull request. + /// + public int CommentCount { get; set; } + } +} diff --git a/src/GitHub.Exports/Models/PullRequestDetailModel.cs b/src/GitHub.Exports/Models/PullRequestDetailModel.cs index 975c6511d2..28cf560a0b 100644 --- a/src/GitHub.Exports/Models/PullRequestDetailModel.cs +++ b/src/GitHub.Exports/Models/PullRequestDetailModel.cs @@ -6,37 +6,12 @@ namespace GitHub.Models /// /// Holds the details of a Pull Request. /// - public class PullRequestDetailModel + public class PullRequestDetailModel : IssueishDetailModel { - /// - /// 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 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 pull request body markdown. - /// - public string Body { get; set; } + public PullRequestState State { get; set; } /// /// Gets or sets the name of the base branch (e.g. "master"). @@ -67,17 +42,22 @@ public class PullRequestDetailModel /// Gets or sets the owner login of the repository containing the head branch. /// public string HeadRepositoryOwner { get; set; } - - /// - /// Gets or sets the date/time at which the pull request was last updated. - /// - public DateTimeOffset UpdatedAt { get; set; } /// /// Gets or sets a collection of files changed by the pull request. /// public IReadOnlyList ChangedFiles { get; set; } + /// + /// Gets or sets a collection of pull request Checks Suites. + /// + public IReadOnlyList CheckSuites { get; set; } + + /// + /// Gets or sets a collection of pull request Statuses + /// + public IReadOnlyList Statuses { get; set; } + /// /// Gets or sets a collection of pull request reviews. /// @@ -93,13 +73,8 @@ public class PullRequestDetailModel public IReadOnlyList Threads { get; set; } /// - /// Gets or sets a collection of pull request Checks Suites - /// - public IReadOnlyList CheckSuites { get; set; } - - /// - /// Gets or sets a collection of pull request Statuses + /// Gets or sets the pull request timeline entries. /// - public IReadOnlyList Statuses { get; set; } + public IReadOnlyList Timeline { get; set; } } } diff --git a/src/GitHub.Exports/Models/PullRequestListItemModel.cs b/src/GitHub.Exports/Models/PullRequestListItemModel.cs index 0301af0da9..fa3a71fb9a 100644 --- a/src/GitHub.Exports/Models/PullRequestListItemModel.cs +++ b/src/GitHub.Exports/Models/PullRequestListItemModel.cs @@ -35,7 +35,7 @@ public class PullRequestListItemModel /// /// Gets or sets the pull request state (open, closed, merged). /// - public PullRequestStateEnum State { get; set; } + public PullRequestState State { get; set; } /// /// Gets the pull request checks and statuses summary diff --git a/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs b/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs index ef224242e1..535429d3c5 100644 --- a/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs +++ b/src/GitHub.Exports/Models/PullRequestReviewCommentModel.cs @@ -7,6 +7,16 @@ namespace GitHub.Models /// public class PullRequestReviewCommentModel : CommentModel { + /// + /// Gets the PullRequestId of the comment. + /// + /// + /// The GraphQL Api does not allow for deleting of pull request comments. + /// REST Api must be used, and PullRequestId is needed to reload the pull request. + /// This field should be removed with better GraphQL support. + /// + public int PullRequestId { get; set; } + /// /// Gets or sets the associated thread that contains the comment. /// diff --git a/src/GitHub.Exports/Models/UsageModel.cs b/src/GitHub.Exports/Models/UsageModel.cs index f91ffd7448..751b443c5f 100644 --- a/src/GitHub.Exports/Models/UsageModel.cs +++ b/src/GitHub.Exports/Models/UsageModel.cs @@ -95,6 +95,7 @@ public class MeasuresModel public int NumberOfGitHubOpens { get; set; } public int NumberOfEnterpriseOpens { get; set; } public int NumberOfClonesToDefaultClonePath { get; set; } + public int NumberOfPRConversationsOpened { get; set; } } } } diff --git a/src/GitHub.Exports/Services/IGitHubToolWindowManager.cs b/src/GitHub.Exports/Services/IGitHubToolWindowManager.cs new file mode 100644 index 0000000000..e7ee14312d --- /dev/null +++ b/src/GitHub.Exports/Services/IGitHubToolWindowManager.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using GitHub.ViewModels.GitHubPane; +using GitHub.ViewModels.Documents; +using GitHub.Primitives; + +namespace GitHub.Services +{ + /// + /// The Visual Studio service interface for accessing the GitHub Pane. + /// + [Guid("FC9EC5B5-C297-4548-A229-F8E16365543C")] + [ComVisible(true)] + public interface IGitHubToolWindowManager + { + /// + /// Ensure that the GitHub pane is created and visible. + /// + /// The view model for the GitHub Pane. + Task ShowGitHubPane(); + + /// + /// Shows a document-like tool window pane for an issue or pull request. + /// + /// + /// The host address of the server that hosts the issue or pull request. + /// + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + /// The view model for the document pane. + Task ShowIssueishDocumentPane( + HostAddress address, + string owner, + string repository, + int number); + } +} diff --git a/src/GitHub.Exports/Services/ITeamExplorerServices.cs b/src/GitHub.Exports/Services/ITeamExplorerServices.cs index 35e94b0044..f25454c220 100644 --- a/src/GitHub.Exports/Services/ITeamExplorerServices.cs +++ b/src/GitHub.Exports/Services/ITeamExplorerServices.cs @@ -5,6 +5,7 @@ namespace GitHub.Services public interface ITeamExplorerServices : INotificationService { void ShowConnectPage(); + void ShowCommitDetails(string oid); void ShowHomePage(); void ShowPublishSection(); Task ShowRepositorySettingsRemotesAsync(); diff --git a/src/GitHub.Exports/Settings/PkgCmdID.cs b/src/GitHub.Exports/Settings/PkgCmdID.cs index 636905c377..64160c738e 100644 --- a/src/GitHub.Exports/Settings/PkgCmdID.cs +++ b/src/GitHub.Exports/Settings/PkgCmdID.cs @@ -13,6 +13,7 @@ public static class PkgCmdIDList public const int syncSubmodulesCommand = 0x203; public const int openFromUrlCommand = 0x204; public const int openFromClipboardCommand = 0x205; + public const int showIssueishDocumentCommand = 0x206; public const int backCommand = 0x300; public const int forwardCommand = 0x301; diff --git a/src/GitHub.Exports/ViewModels/Documents/IIssueishPaneViewModel.cs b/src/GitHub.Exports/ViewModels/Documents/IIssueishPaneViewModel.cs new file mode 100644 index 0000000000..60d6a6ddea --- /dev/null +++ b/src/GitHub.Exports/ViewModels/Documents/IIssueishPaneViewModel.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using GitHub.Models; + +namespace GitHub.ViewModels.Documents +{ + /// + /// View model for an issue or pull request document pane. + /// + public interface IIssueishPaneViewModel : IPaneViewModel + { + /// + /// Gets the content to display in the document pane. + /// + IViewModel Content { get; } + + /// + /// Gets a value indicating whether + /// has been called on the view model. + /// + bool IsInitialized { get; } + + /// + /// Loads an issue or pull request into the view model. + /// + /// The connection to use. + /// The repository owner. + /// The repository name. + /// The issue or pull request number. + /// A task that will complete when the load has finished. + Task Load( + IConnection connection, + string owner, + string name, + int number); + } +} diff --git a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubToolWindowManager.cs b/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubToolWindowManager.cs deleted file mode 100644 index cd1c71701b..0000000000 --- a/src/GitHub.Exports/ViewModels/GitHubPane/IGitHubToolWindowManager.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Threading.Tasks; -using System.Runtime.InteropServices; - -namespace GitHub.ViewModels.GitHubPane -{ - /// - /// The Visual Studio service interface for accessing the GitHub Pane. - /// - [Guid("FC9EC5B5-C297-4548-A229-F8E16365543C")] - [ComVisible(true)] - public interface IGitHubToolWindowManager - { - /// - /// Ensure that the GitHub pane is created and visible. - /// - /// The view model for the GitHub Pane. - Task ShowGitHubPane(); - } -} diff --git a/src/GitHub.Exports/ViewModels/IPaneViewModel.cs b/src/GitHub.Exports/ViewModels/IPaneViewModel.cs new file mode 100644 index 0000000000..142ada1f77 --- /dev/null +++ b/src/GitHub.Exports/ViewModels/IPaneViewModel.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; + +namespace GitHub.ViewModels +{ + /// + /// Represents the top-level content in a Visual Studio ToolWindowPane. + /// + public interface IPaneViewModel : IViewModel + { + /// + /// Gets the caption for the tool window. + /// + string PaneCaption { get; } + + /// + /// Initializes the view model. + /// + /// + /// The service provider for the containing ToolWindowPane. + /// + Task InitializeAsync(IServiceProvider paneServiceProvider); + } +} \ No newline at end of file diff --git a/src/GitHub.Exports/ViewModels/ISpinnerViewModel.cs b/src/GitHub.Exports/ViewModels/ISpinnerViewModel.cs new file mode 100644 index 0000000000..2478ed572b --- /dev/null +++ b/src/GitHub.Exports/ViewModels/ISpinnerViewModel.cs @@ -0,0 +1,11 @@ +using System; + +namespace GitHub.ViewModels +{ + /// + /// View model which displays a spinner. + /// + public interface ISpinnerViewModel : IViewModel + { + } +} diff --git a/src/GitHub.InlineReviews/Services/PullRequestSession.cs b/src/GitHub.InlineReviews/Services/PullRequestSession.cs index 29f232edc2..e868b95cbe 100644 --- a/src/GitHub.InlineReviews/Services/PullRequestSession.cs +++ b/src/GitHub.InlineReviews/Services/PullRequestSession.cs @@ -304,7 +304,6 @@ async Task AddComment(PullRequestReviewCommentModel comment) async Task UpdateFile(PullRequestSessionFile file) { - await Task.Delay(0); var mergeBaseSha = await GetMergeBase(); file.BaseSha = PullRequest.BaseRefSha; file.CommitSha = file.IsTrackingHead ? PullRequest.HeadRefSha : file.CommitSha; diff --git a/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs b/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs index c67d106bf1..e470647330 100644 --- a/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs +++ b/src/GitHub.InlineReviews/Services/PullRequestSessionService.cs @@ -293,7 +293,7 @@ public virtual async Task ReadPullRequestDetail(HostAddr { readPullRequest = new Query() .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) - .PullRequest(Var(nameof(number))) + .PullRequest(number: Var(nameof(number))) .Select(pr => new PullRequestDetailModel { Id = pr.Id.Value, @@ -313,6 +313,20 @@ public virtual async Task ReadPullRequestDetail(HostAddr HeadRepositoryOwner = pr.HeadRepositoryOwner != null ? pr.HeadRepositoryOwner.Login : null, State = pr.State.FromGraphQl(), UpdatedAt = pr.UpdatedAt, + CommentCount = pr.Comments(0, null, null, null).TotalCount, + Comments = pr.Comments(null, null, null, null).AllPages().Select(comment => new CommentModel + { + Id = comment.Id.Value, + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Url = comment.Url, + }).ToList(), Reviews = pr.Reviews(null, null, null, null, null, null).AllPages().Select(review => new PullRequestReviewModel { Id = review.Id.Value, @@ -347,6 +361,31 @@ public virtual async Task ReadPullRequestDetail(HostAddr Url = comment.Url, }).ToList(), }).ToList(), + Timeline = pr.Timeline(null, null, null, null, null).AllPages().Select(item => item.Switch(when => + when.Commit(commit => new CommitModel + { + AbbreviatedOid = commit.AbbreviatedOid, + // TODO: commit.Author.User can be null + Author = new ActorModel + { + Login = commit.Author.User.Login, + AvatarUrl = commit.Author.User.AvatarUrl(null), + }, + MessageHeadline = commit.MessageHeadline, + Oid = commit.Oid, + }).IssueComment(comment => new CommentModel + { + Author = new ActorModel + { + Login = comment.Author.Login, + AvatarUrl = comment.Author.AvatarUrl(null), + }, + Body = comment.Body, + CreatedAt = comment.CreatedAt, + DatabaseId = comment.DatabaseId.Value, + Id = comment.Id.Value, + Url = comment.Url, + }))).ToList() }).Compile(); } @@ -785,7 +824,7 @@ async Task GetPullRequestLastCommitAdapter(HostAddress addres { readCommitStatuses = new Query() .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) - .PullRequest(Var(nameof(number))).Commits(last: 1).Nodes.Select( + .PullRequest(number: Var(nameof(number))).Commits(last: 1).Nodes.Select( commit => new LastCommitAdapter { HeadSha = commit.Commit.Oid, @@ -837,7 +876,7 @@ async Task GetPullRequestLastCommitAdapter(HostAddress addres { readCommitStatusesEnterprise = new Query() .Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) - .PullRequest(Var(nameof(number))).Commits(last: 1).Nodes.Select( + .PullRequest(number: Var(nameof(number))).Commits(last: 1).Nodes.Select( commit => new LastCommitAdapter { Statuses = commit.Commit.Status == null ? null : commit.Commit.Status diff --git a/src/GitHub.Resources/Resources.Designer.cs b/src/GitHub.Resources/Resources.Designer.cs index 4b75172d7b..a22ae723f1 100644 --- a/src/GitHub.Resources/Resources.Designer.cs +++ b/src/GitHub.Resources/Resources.Designer.cs @@ -69,6 +69,15 @@ public static string AddedFileStatus { } } + /// + /// Looks up a localized string similar to added some commits. + /// + public static string AddedSomeCommits { + get { + return ResourceManager.GetString("AddedSomeCommits", resourceCulture); + } + } + /// /// Looks up a localized string similar to Add review comment. /// @@ -96,6 +105,15 @@ public static string AddYourReview { } } + /// + /// Looks up a localized string similar to and others. + /// + public static string AndOthers { + get { + return ResourceManager.GetString("AndOthers", resourceCulture); + } + } + /// /// Looks up a localized string similar to Approve. /// @@ -312,6 +330,42 @@ public static string CloneTitle { } } + /// + /// Looks up a localized string similar to Close and comment. + /// + public static string CloseAndComment { + get { + return ResourceManager.GetString("CloseAndComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Close issue. + /// + public static string CloseIssue { + get { + return ResourceManager.GetString("CloseIssue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Close pull request. + /// + public static string ClosePullRequest { + get { + return ResourceManager.GetString("ClosePullRequest", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Comment. + /// + public static string Comment { + get { + return ResourceManager.GetString("Comment", resourceCulture); + } + } + /// /// Looks up a localized string similar to Commented. /// @@ -339,6 +393,15 @@ public static string Comments { } } + /// + /// Looks up a localized string similar to {0} commits. + /// + public static string CommitCountFormat { + get { + return ResourceManager.GetString("CommitCountFormat", resourceCulture); + } + } + /// /// Looks up a localized string similar to Compare File as Default Action. /// @@ -1224,6 +1287,15 @@ public static string Open { } } + /// + /// Looks up a localized string similar to Open Conversation. + /// + public static string OpenConversation { + get { + return ResourceManager.GetString("OpenConversation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Open File as Default Action. /// @@ -1611,6 +1683,33 @@ public static string RenamedFileStatus { } } + /// + /// Looks up a localized string similar to Reopen and comment. + /// + public static string ReopenAndComment { + get { + return ResourceManager.GetString("ReopenAndComment", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reopen issue. + /// + public static string ReopenIssue { + get { + return ResourceManager.GetString("ReopenIssue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Reopen pull request. + /// + public static string ReopenPullRequest { + get { + return ResourceManager.GetString("ReopenPullRequest", resourceCulture); + } + } + /// /// Looks up a localized string similar to This repository does not have a remote. Fill out the form to publish it to GitHub.. /// diff --git a/src/GitHub.Resources/Resources.resx b/src/GitHub.Resources/Resources.resx index 003b8be2e6..1b909be5ee 100644 --- a/src/GitHub.Resources/Resources.resx +++ b/src/GitHub.Resources/Resources.resx @@ -848,4 +848,37 @@ https://git-scm.com/download/win {0} most recently pushed + + Open Conversation + + + Comment + + + Close pull request + + + Close issue + + + Close and comment + + + Reopen and comment + + + Reopen issue + + + Reopen pull request + + + {0} commits + + + added some commits + + + and others + \ No newline at end of file diff --git a/src/GitHub.TeamFoundation.14/Services/TeamExplorerServices.cs b/src/GitHub.TeamFoundation.14/Services/TeamExplorerServices.cs index 760eca331a..404b1d728f 100644 --- a/src/GitHub.TeamFoundation.14/Services/TeamExplorerServices.cs +++ b/src/GitHub.TeamFoundation.14/Services/TeamExplorerServices.cs @@ -52,6 +52,12 @@ public void ShowConnectPage() te.NavigateToPage(new Guid(TeamExplorerPageIds.Connect), null); } + public void ShowCommitDetails(string oid) + { + var te = serviceProvider.TryGetService(); + te.NavigateToPage(new Guid(TeamExplorerPageIds.GitCommitDetails), oid); + } + public void ShowHomePage() { var te = serviceProvider.TryGetService(); diff --git a/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj b/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj index 9122552da0..77990b4539 100644 --- a/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj +++ b/src/GitHub.VisualStudio.UI/GitHub.VisualStudio.UI.csproj @@ -10,6 +10,7 @@ + @@ -29,6 +30,9 @@ + + IssueishCommentView.xaml + NoRemoteOriginView.xaml diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml index 02e1b632f4..8863e8ffb2 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeBlue.xaml @@ -66,4 +66,6 @@ + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml index b9994994e7..d47c6cbadc 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeDark.xaml @@ -12,8 +12,8 @@ #FF0097FB - - #FF2D2D30 + + #FF3f3f46 #FFFFFFFF @@ -66,4 +66,6 @@ + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml b/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml index 85fcdf79b5..5ccf074b68 100644 --- a/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml +++ b/src/GitHub.VisualStudio.UI/Styles/ThemeLight.xaml @@ -13,7 +13,7 @@ #FF0E70C0 - #FFEEEEF2 + #FFcccedb #FF1B293E @@ -66,4 +66,6 @@ + + \ No newline at end of file diff --git a/src/GitHub.VisualStudio.UI/Views/CommentThreadView.xaml b/src/GitHub.VisualStudio.UI/Views/CommentThreadView.xaml index ec7257d64b..79c8422452 100644 --- a/src/GitHub.VisualStudio.UI/Views/CommentThreadView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/CommentThreadView.xaml @@ -13,7 +13,7 @@ - + diff --git a/src/GitHub.VisualStudio.UI/Views/Documents/CommitListView.xaml b/src/GitHub.VisualStudio.UI/Views/Documents/CommitListView.xaml new file mode 100644 index 0000000000..3929fd36ef --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Documents/CommitListView.xaml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio.UI/Views/Documents/CommitListView.xaml.cs b/src/GitHub.VisualStudio.UI/Views/Documents/CommitListView.xaml.cs new file mode 100644 index 0000000000..b46583ec19 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Documents/CommitListView.xaml.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.Composition; +using System.Windows.Controls; +using GitHub.Exports; +using GitHub.ViewModels.Documents; + +namespace GitHub.VisualStudio.Views.Documents +{ + [ExportViewFor(typeof(ICommitListViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class CommitListView : UserControl + { + public CommitListView() + { + InitializeComponent(); + } + } +} diff --git a/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml b/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml new file mode 100644 index 0000000000..e1ced83417 --- /dev/null +++ b/src/GitHub.VisualStudio.UI/Views/Documents/IssueishCommentView.xaml @@ -0,0 +1,204 @@ + + + + + You can use a `CompositeDisposable` type here, it's designed to handle disposables in an optimal way (you can just call `Dispose()` on it and it will handle disposing everything it holds). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +