diff --git a/src/GitHub.App/GitHub.App.csproj b/src/GitHub.App/GitHub.App.csproj index 815b12f40b..17331a586e 100644 --- a/src/GitHub.App/GitHub.App.csproj +++ b/src/GitHub.App/GitHub.App.csproj @@ -61,8 +61,16 @@ ..\..\packages\Microsoft.VisualStudio.ComponentModelHost.14.0.25424\lib\net45\Microsoft.VisualStudio.ComponentModelHost.dll True + + ..\..\packages\Microsoft.VisualStudio.CoreUtility.14.3.25407\lib\net45\Microsoft.VisualStudio.CoreUtility.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Editor.14.3.25407\lib\net45\Microsoft.VisualStudio.Editor.dll + True + - ..\..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6070\lib\Microsoft.VisualStudio.OLE.Interop.dll + ..\..\packages\Microsoft.VisualStudio.OLE.Interop.7.10.6071\lib\Microsoft.VisualStudio.OLE.Interop.dll True @@ -74,21 +82,60 @@ True - ..\..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6071\lib\Microsoft.VisualStudio.Shell.Interop.dll + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.7.10.6072\lib\net11\Microsoft.VisualStudio.Shell.Interop.dll + True + + + True + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.10.0.10.0.30320\lib\net20\Microsoft.VisualStudio.Shell.Interop.10.0.dll + True + + + True + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.11.0.11.0.61031\lib\net20\Microsoft.VisualStudio.Shell.Interop.11.0.dll + True + + + True + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.12.0.12.0.30111\lib\net20\Microsoft.VisualStudio.Shell.Interop.12.0.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Shell.Interop.8.0.8.0.50728\lib\net11\Microsoft.VisualStudio.Shell.Interop.8.0.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.Data.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.Data.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.Logic.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.Logic.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.UI.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.UI.dll + True + + + ..\..\packages\Microsoft.VisualStudio.Text.UI.Wpf.14.3.25407\lib\net45\Microsoft.VisualStudio.Text.UI.Wpf.dll True - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6070\lib\Microsoft.VisualStudio.TextManager.Interop.dll + ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.7.10.6071\lib\net11\Microsoft.VisualStudio.TextManager.Interop.dll True - ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50727\lib\Microsoft.VisualStudio.TextManager.Interop.8.0.dll + ..\..\packages\Microsoft.VisualStudio.TextManager.Interop.8.0.8.0.50728\lib\net11\Microsoft.VisualStudio.TextManager.Interop.8.0.dll True False ..\..\packages\Microsoft.VisualStudio.Threading.14.1.131\lib\net45\Microsoft.VisualStudio.Threading.dll + + ..\..\packages\Microsoft.VisualStudio.Utilities.14.3.25407\lib\net45\Microsoft.VisualStudio.Utilities.dll + True + False ..\..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll @@ -152,11 +199,13 @@ + + @@ -172,6 +221,7 @@ + diff --git a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs index 64126dc3bd..63f163f42a 100644 --- a/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestDetailViewModelDesigner.cs @@ -31,8 +31,6 @@ public class PullRequestUpdateStateDesigner : IPullRequestUpdateState [ExcludeFromCodeCoverage] public class PullRequestDetailViewModelDesigner : PanePageViewModelBase, IPullRequestDetailViewModel { - private List changedFilesTree; - public PullRequestDetailViewModelDesigner() { var repoPath = @"C:\Repo"; @@ -69,8 +67,7 @@ public PullRequestDetailViewModelDesigner() modelsDir.Files.Add(oldBranchModel); gitHubDir.Directories.Add(modelsDir); - changedFilesTree = new List(); - changedFilesTree.Add(gitHubDir); + Files = new PullRequestFilesViewModelDesigner(); } public IPullRequestModel Model { get; } @@ -84,7 +81,7 @@ public PullRequestDetailViewModelDesigner() public bool IsCheckedOut { get; } public bool IsFromFork { get; } public string Body { get; } - public IReadOnlyList ChangedFilesTree => changedFilesTree; + public IPullRequestFilesViewModel Files { get; set; } public IPullRequestCheckoutState CheckoutState { get; set; } public IPullRequestUpdateState UpdateState { get; set; } public string OperationError { get; set; } @@ -94,12 +91,7 @@ public PullRequestDetailViewModelDesigner() public ReactiveCommand Checkout { get; } public ReactiveCommand Pull { get; } public ReactiveCommand Push { get; } - public ReactiveCommand SyncSubmodules { get; } public ReactiveCommand OpenOnGitHub { get; } - public ReactiveCommand DiffFile { get; } - public ReactiveCommand DiffFileWithWorkingDirectory { get; } - public ReactiveCommand OpenFileInWorkingDirectory { get; } - public ReactiveCommand ViewFile { get; } public Task InitializeAsync(ILocalRepositoryModel localRepository, IConnection connection, string owner, string repo, int number) => Task.CompletedTask; diff --git a/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs new file mode 100644 index 0000000000..c37398067a --- /dev/null +++ b/src/GitHub.App/SampleData/PullRequestFilesViewModelDesigner.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using ReactiveUI; + +namespace GitHub.SampleData +{ + public class PullRequestFilesViewModelDesigner : PanePageViewModelBase, IPullRequestFilesViewModel + { + public PullRequestFilesViewModelDesigner() + { + Items = new[] + { + new PullRequestDirectoryNode("src") + { + Files = + { + new PullRequestFileNode("x", "src/File1.cs", "x", PullRequestFileStatus.Added, null), + new PullRequestFileNode("x", "src/File2.cs", "x", PullRequestFileStatus.Modified, null), + new PullRequestFileNode("x", "src/File3.cs", "x", PullRequestFileStatus.Removed, null), + new PullRequestFileNode("x", "src/File4.cs", "x", PullRequestFileStatus.Renamed, "src/Old.cs"), + } + } + }; + ChangedFilesCount = 4; + } + + public int ChangedFilesCount { get; set; } + public IReadOnlyList Items { get; } + public ReactiveCommand DiffFile { get; } + public ReactiveCommand ViewFile { get; } + public ReactiveCommand DiffFileWithWorkingDirectory { get; } + public ReactiveCommand OpenFileInWorkingDirectory { get; } + public ReactiveCommand OpenFirstComment { get; } + + public Task InitializeAsync( + IPullRequestSession session, + Func commentFilter = null) + { + return Task.CompletedTask; + } + } +} diff --git a/src/GitHub.App/Services/PullRequestEditorService.cs b/src/GitHub.App/Services/PullRequestEditorService.cs index b8c758fa39..563b176f58 100644 --- a/src/GitHub.App/Services/PullRequestEditorService.cs +++ b/src/GitHub.App/Services/PullRequestEditorService.cs @@ -1,25 +1,200 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using GitHub.Commands; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.ViewModels.GitHubPane; +using GitHub.VisualStudio; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Editor; using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Projection; using Microsoft.VisualStudio.TextManager.Interop; -using GitHub.Models; +using Task = System.Threading.Tasks.Task; namespace GitHub.Services { + /// + /// Services for opening views of pull request files in Visual Studio. + /// [Export(typeof(IPullRequestEditorService))] + [PartCreationPolicy(CreationPolicy.Shared)] public class PullRequestEditorService : IPullRequestEditorService { - readonly IGitHubServiceProvider serviceProvider; - // If the target line doesn't have a unique match, search this number of lines above looking for a match. public const int MatchLinesAboveTarget = 4; + readonly IGitHubServiceProvider serviceProvider; + readonly IPullRequestService pullRequestService; + readonly IVsEditorAdaptersFactoryService vsEditorAdaptersFactory; + readonly IStatusBarNotificationService statusBar; + readonly IUsageTracker usageTracker; + [ImportingConstructor] - public PullRequestEditorService(IGitHubServiceProvider serviceProvider) + public PullRequestEditorService( + IGitHubServiceProvider serviceProvider, + IPullRequestService pullRequestService, + IVsEditorAdaptersFactoryService vsEditorAdaptersFactory, + IStatusBarNotificationService statusBar, + IUsageTracker usageTracker) { + Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider)); + Guard.ArgumentNotNull(pullRequestService, nameof(pullRequestService)); + Guard.ArgumentNotNull(vsEditorAdaptersFactory, nameof(vsEditorAdaptersFactory)); + Guard.ArgumentNotNull(statusBar, nameof(statusBar)); + Guard.ArgumentNotNull(usageTracker, nameof(usageTracker)); + this.serviceProvider = serviceProvider; + this.pullRequestService = pullRequestService; + this.vsEditorAdaptersFactory = vsEditorAdaptersFactory; + this.statusBar = statusBar; + this.usageTracker = usageTracker; + } + + /// + public async Task OpenFile( + IPullRequestSession session, + string relativePath, + bool workingDirectory) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + + try + { + var file = await session.GetFile(relativePath); + var fullPath = GetAbsolutePath(session, file); + var fileName = workingDirectory ? fullPath : await ExtractFile(session, file, true); + + using (workingDirectory ? null : OpenInProvisionalTab()) + { + var window = VisualStudio.Services.Dte.ItemOperations.OpenFile(fileName); + window.Document.ReadOnly = !workingDirectory; + + var buffer = GetBufferAt(fileName); + + if (!workingDirectory) + { + AddBufferTag(buffer, session, fullPath, null); + } + } + + if (workingDirectory) + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsOpenFileInSolution); + else + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewFile); + } + catch (Exception e) + { + ShowErrorInStatusBar("Error opening file", e); + } + } + + /// + public async Task OpenDiff( + IPullRequestSession session, + string relativePath, + bool workingDirectory) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + + try + { + var file = await session.GetFile(relativePath); + var rightPath = file.RelativePath; + var leftPath = await GetBaseFileName(session, file); + var rightFile = workingDirectory ? GetAbsolutePath(session, file) : await ExtractFile(session, file, true); + var leftFile = await ExtractFile(session, file, false); + var leftLabel = $"{leftPath};{session.GetBaseBranchDisplay()}"; + var rightLabel = workingDirectory ? rightPath : $"{rightPath};PR {session.PullRequest.Number}"; + var caption = $"Diff - {Path.GetFileName(file.RelativePath)}"; + var options = __VSDIFFSERVICEOPTIONS.VSDIFFOPT_DetectBinaryFiles | + __VSDIFFSERVICEOPTIONS.VSDIFFOPT_LeftFileIsTemporary; + + if (!workingDirectory) + { + options |= __VSDIFFSERVICEOPTIONS.VSDIFFOPT_RightFileIsTemporary; + } + + IVsWindowFrame frame; + using (OpenInProvisionalTab()) + { + var tooltip = $"{leftLabel}\nvs.\n{rightLabel}"; + + // Diff window will open in provisional (right hand) tab until document is touched. + frame = VisualStudio.Services.DifferenceService.OpenComparisonWindow2( + leftFile, + rightFile, + caption, + tooltip, + leftLabel, + rightLabel, + string.Empty, + string.Empty, + (uint)options); + } + + object docView; + frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView); + var diffViewer = ((IVsDifferenceCodeWindow)docView).DifferenceViewer; + + AddBufferTag(diffViewer.LeftView.TextBuffer, session, leftPath, DiffSide.Left); + + if (!workingDirectory) + { + AddBufferTag(diffViewer.RightView.TextBuffer, session, rightPath, DiffSide.Right); + EnableNavigateToEditor(diffViewer.LeftView, session, file); + EnableNavigateToEditor(diffViewer.RightView, session, file); + EnableNavigateToEditor(diffViewer.InlineView, session, file); + } + + if (workingDirectory) + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsCompareWithSolution); + else + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewChanges); + } + catch (Exception e) + { + ShowErrorInStatusBar("Error opening file", e); + } + } + + /// + public async Task OpenDiff( + IPullRequestSession session, + string relativePath, + IInlineCommentThreadModel thread) + { + Guard.ArgumentNotNull(session, nameof(session)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); + Guard.ArgumentNotNull(thread, nameof(thread)); + + await OpenDiff(session, relativePath, false); + + // HACK: We need to wait here for the diff view to set itself up and move its cursor + // to the first changed line. There must be a better way of doing this. + await Task.Delay(1500); + + var param = (object)new InlineCommentNavigationParams + { + FromLine = thread.LineNumber - 1, + }; + + VisualStudio.Services.Dte.Commands.Raise( + Guids.CommandSetString, + PkgCmdIDList.NextInlineCommentId, + ref param, + null); } public IVsTextView NavigateToEquivalentPosition(IVsTextView sourceView, string targetFile) @@ -180,6 +355,152 @@ IVsTextView OpenDocument(string fullPath) return view; } + void ShowErrorInStatusBar(string message) + { + statusBar.ShowMessage(message); + } + + void ShowErrorInStatusBar(string message, Exception e) + { + statusBar.ShowMessage(message + ": " + e.Message); + } + + void AddBufferTag(ITextBuffer buffer, IPullRequestSession session, string path, DiffSide? side) + { + buffer.Properties.GetOrCreateSingletonProperty( + typeof(PullRequestTextBufferInfo), + () => new PullRequestTextBufferInfo(session, path, side)); + + var projection = buffer as IProjectionBuffer; + + if (projection != null) + { + foreach (var source in projection.SourceBuffers) + { + AddBufferTag(source, session, path, side); + } + } + } + + void EnableNavigateToEditor(IWpfTextView textView, IPullRequestSession session, IPullRequestSessionFile file) + { + var view = vsEditorAdaptersFactory.GetViewAdapter(textView); + EnableNavigateToEditor(view, session, file); + } + + void EnableNavigateToEditor(IVsTextView textView, IPullRequestSession session, IPullRequestSessionFile file) + { + var commandGroup = VSConstants.CMDSETID.StandardCommandSet2K_guid; + var commandId = (int)VSConstants.VSStd2KCmdID.RETURN; + new TextViewCommandDispatcher(textView, commandGroup, commandId).Exec += + async (s, e) => await DoNavigateToEditor(session, file); + + var contextMenuCommandGroup = new Guid(Guids.guidContextMenuSetString); + var goToCommandId = PkgCmdIDList.openFileInSolutionCommand; + new TextViewCommandDispatcher(textView, contextMenuCommandGroup, goToCommandId).Exec += + async (s, e) => await DoNavigateToEditor(session, file); + } + + async Task DoNavigateToEditor(IPullRequestSession session, IPullRequestSessionFile file) + { + try + { + if (!session.IsCheckedOut) + { + ShowInfoMessage("Checkout PR branch before opening file in solution."); + return; + } + + var fullPath = GetAbsolutePath(session, file); + + var activeView = FindActiveView(); + if (activeView == null) + { + ShowErrorInStatusBar("Couldn't find active view"); + return; + } + + NavigateToEquivalentPosition(activeView, fullPath); + + await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsNavigateToEditor); + } + catch (Exception e) + { + ShowErrorInStatusBar("Error navigating to editor", e); + } + } + + async Task ExtractFile(IPullRequestSession session, IPullRequestSessionFile file, bool head) + { + var encoding = pullRequestService.GetEncoding(session.LocalRepository, file.RelativePath); + var relativePath = head ? file.RelativePath : await GetBaseFileName(session, file); + + return await pullRequestService.ExtractFile( + session.LocalRepository, + session.PullRequest, + relativePath, + head, + encoding).ToTask(); + } + + ITextBuffer GetBufferAt(string filePath) + { + IVsUIHierarchy uiHierarchy; + uint itemID; + IVsWindowFrame windowFrame; + + if (VsShellUtilities.IsDocumentOpen( + serviceProvider, + filePath, + Guid.Empty, + out uiHierarchy, + out itemID, + out windowFrame)) + { + IVsTextView view = VsShellUtilities.GetTextView(windowFrame); + IVsTextLines lines; + if (view.GetBuffer(out lines) == 0) + { + var buffer = lines as IVsTextBuffer; + if (buffer != null) + return vsEditorAdaptersFactory.GetDataBuffer(buffer); + } + } + + return null; + } + + async Task GetBaseFileName(IPullRequestSession session, IPullRequestSessionFile file) + { + using (var changes = await pullRequestService.GetTreeChanges( + session.LocalRepository, + session.PullRequest)) + { + var fileChange = changes.FirstOrDefault(x => x.Path == file.RelativePath); + return fileChange?.Status == LibGit2Sharp.ChangeKind.Renamed ? + fileChange.OldPath : file.RelativePath; + } + } + + void ShowInfoMessage(string message) + { + ErrorHandler.ThrowOnFailure(VsShellUtilities.ShowMessageBox( + serviceProvider, message, null, + OLEMSGICON.OLEMSGICON_INFO, OLEMSGBUTTON.OLEMSGBUTTON_OK, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST)); + } + + static string GetAbsolutePath(IPullRequestSession session, IPullRequestSessionFile file) + { + return Path.Combine(session.LocalRepository.LocalPath, file.RelativePath); + } + + static IDisposable OpenInProvisionalTab() + { + return new NewDocumentStateScope( + __VSNEWDOCUMENTSTATE.NDS_Provisional, + VSConstants.NewDocumentStateReason.SolutionExplorer); + } + static IList ReadLines(string text) { var lines = new List(); diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/TextViewCommandDispatcher.cs b/src/GitHub.App/Services/TextViewCommandDispatcher.cs similarity index 98% rename from src/GitHub.VisualStudio/Views/GitHubPane/TextViewCommandDispatcher.cs rename to src/GitHub.App/Services/TextViewCommandDispatcher.cs index 10c7da216e..dd0f91f582 100644 --- a/src/GitHub.VisualStudio/Views/GitHubPane/TextViewCommandDispatcher.cs +++ b/src/GitHub.App/Services/TextViewCommandDispatcher.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.OLE.Interop; using Microsoft.VisualStudio.TextManager.Interop; -namespace GitHub.VisualStudio.Views.GitHubPane +namespace GitHub.Services { /// /// Intercepts all commands sent to a and fires when a specified command is encountered. diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs index ad4c66f0c0..f75616cc16 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModel.cs @@ -42,7 +42,6 @@ public sealed class PullRequestDetailViewModel : PanePageViewModelBase, IPullReq string targetBranchDisplayName; int commentCount; string body; - IReadOnlyList changedFilesTree; IPullRequestCheckoutState checkoutState; IPullRequestUpdateState updateState; string operationError; @@ -69,7 +68,8 @@ public PullRequestDetailViewModel( IModelServiceFactory modelServiceFactory, IUsageTracker usageTracker, ITeamExplorerContext teamExplorerContext, - IStatusBarNotificationService statusBarNotificationService) + IStatusBarNotificationService statusBarNotificationService, + IPullRequestFilesViewModel files) { Guard.ArgumentNotNull(pullRequestsService, nameof(pullRequestsService)); Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); @@ -84,6 +84,7 @@ public PullRequestDetailViewModel( this.usageTracker = usageTracker; this.teamExplorerContext = teamExplorerContext; this.statusBarNotificationService = statusBarNotificationService; + Files = files; Checkout = ReactiveCommand.CreateAsyncObservable( this.WhenAnyValue(x => x.CheckoutState) @@ -116,10 +117,6 @@ public PullRequestDetailViewModel( SubscribeOperationError(SyncSubmodules); OpenOnGitHub = ReactiveCommand.Create(); - DiffFile = ReactiveCommand.Create(); - DiffFileWithWorkingDirectory = ReactiveCommand.Create(this.WhenAnyValue(x => x.IsCheckedOut)); - OpenFileInWorkingDirectory = ReactiveCommand.Create(this.WhenAnyValue(x => x.IsCheckedOut)); - ViewFile = ReactiveCommand.Create(); } /// @@ -248,13 +245,9 @@ public string OperationError } /// - /// Gets the changed files as a tree. + /// Gets the pull request's changed files. /// - public IReadOnlyList ChangedFilesTree - { - get { return changedFilesTree; } - private set { this.RaiseAndSetIfChanged(ref changedFilesTree, value); } - } + public IPullRequestFilesViewModel Files { get; } /// /// Gets the web URL for the pull request. @@ -290,27 +283,6 @@ public Uri WebUrl /// public ReactiveCommand OpenOnGitHub { get; } - /// - /// Gets a command that diffs an between BASE and HEAD. - /// - public ReactiveCommand DiffFile { get; } - - /// - /// Gets a command that diffs an between the version in - /// the working directory and HEAD. - /// - public ReactiveCommand DiffFileWithWorkingDirectory { get; } - - /// - /// Gets a command that opens an from disk. - /// - public ReactiveCommand OpenFileInWorkingDirectory { get; } - - /// - /// Gets a command that opens an as it appears in the PR. - /// - public ReactiveCommand ViewFile { get; } - /// /// Initializes the view model. /// @@ -381,9 +353,7 @@ public async Task Load(IPullRequestModel pullRequest) TargetBranchDisplayName = GetBranchDisplayName(IsFromFork, pullRequest.Base?.Label); CommentCount = pullRequest.Comments.Count + pullRequest.ReviewComments.Count; Body = !string.IsNullOrWhiteSpace(pullRequest.Body) ? pullRequest.Body : Resources.NoDescriptionProvidedMarkdown; - - var changes = await pullRequestsService.GetTreeChanges(LocalRepository, pullRequest); - ChangedFilesTree = (await CreateChangedFilesTree(pullRequest, changes)).Children.ToList(); + await Files.InitializeAsync(Session); var localBranches = await pullRequestsService.GetLocalBranches(LocalRepository, pullRequest).ToList(); @@ -507,15 +477,15 @@ public override async Task Refresh() /// The path to a temporary file. public Task ExtractFile(IPullRequestFileNode file, bool head) { - var relativePath = Path.Combine(file.DirectoryPath, file.FileName); - var encoding = pullRequestsService.GetEncoding(LocalRepository, relativePath); + var path = file.RelativePath; + var encoding = pullRequestsService.GetEncoding(LocalRepository, path); if (!head && file.OldPath != null) { - relativePath = file.OldPath; + path = file.OldPath; } - return pullRequestsService.ExtractFile(LocalRepository, model, relativePath, head, encoding).ToTask(); + return pullRequestsService.ExtractFile(LocalRepository, model, path, head, encoding).ToTask(); } /// @@ -525,7 +495,7 @@ public Task ExtractFile(IPullRequestFileNode file, bool head) /// The full path to the file in the working directory. public string GetLocalFilePath(IPullRequestFileNode file) { - return Path.Combine(LocalRepository.LocalPath, file.DirectoryPath, file.FileName); + return Path.Combine(LocalRepository.LocalPath, file.RelativePath); } /// @@ -560,54 +530,6 @@ void SubscribeOperationError(ReactiveCommand command) command.IsExecuting.Select(x => x).Subscribe(x => OperationError = null); } - async Task CreateChangedFilesTree(IPullRequestModel pullRequest, TreeChanges changes) - { - var dirs = new Dictionary - { - { string.Empty, new PullRequestDirectoryNode(string.Empty) } - }; - - foreach (var changedFile in pullRequest.ChangedFiles) - { - var node = new PullRequestFileNode( - LocalRepository.LocalPath, - changedFile.FileName, - changedFile.Sha, - changedFile.Status, - GetOldFileName(changedFile, changes)); - - var file = await Session.GetFile(changedFile.FileName); - var fileCommentCount = file?.WhenAnyValue(x => x.InlineCommentThreads) - .Subscribe(x => node.CommentCount = x.Count(y => y.LineNumber != -1)); - - var dir = GetDirectory(node.DirectoryPath, dirs); - dir.Files.Add(node); - } - - return dirs[string.Empty]; - } - - static PullRequestDirectoryNode GetDirectory(string path, Dictionary dirs) - { - PullRequestDirectoryNode dir; - - if (!dirs.TryGetValue(path, out dir)) - { - var parentPath = Path.GetDirectoryName(path); - var parentDir = GetDirectory(parentPath, dirs); - - dir = new PullRequestDirectoryNode(path); - - if (!parentDir.Directories.Any(x => x.DirectoryName == dir.DirectoryName)) - { - parentDir.Directories.Add(dir); - dirs.Add(path, dir); - } - } - - return dir; - } - static string GetBranchDisplayName(bool isFromFork, string targetBranchLabel) { if (targetBranchLabel != null) @@ -620,17 +542,6 @@ static string GetBranchDisplayName(bool isFromFork, string targetBranchLabel) } } - string GetOldFileName(IPullRequestFileModel file, TreeChanges changes) - { - if (file.Status == PullRequestFileStatus.Renamed) - { - var fileName = file.FileName.Replace("/", "\\"); - return changes?.Renamed.FirstOrDefault(x => x.Path == fileName)?.OldPath; - } - - return null; - } - IObservable DoCheckout(object unused) { return Observable.Defer(async () => diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs index 26b12b1dad..6a9d46ebde 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestDirectoryNode.cs @@ -13,10 +13,10 @@ public class PullRequestDirectoryNode : IPullRequestDirectoryNode /// Initializes a new instance of the class. /// /// The path to the directory, relative to the repository. - public PullRequestDirectoryNode(string fullPath) + public PullRequestDirectoryNode(string relativePath) { - DirectoryName = System.IO.Path.GetFileName(fullPath); - DirectoryPath = fullPath; + DirectoryName = System.IO.Path.GetFileName(relativePath); + RelativePath = relativePath.Replace("/", "\\"); Directories = new List(); Files = new List(); } @@ -27,9 +27,9 @@ public PullRequestDirectoryNode(string fullPath) public string DirectoryName { get; } /// - /// Gets the full directory path, relative to the root of the repository. + /// Gets the path to the directory, relative to the root of the repository. /// - public string DirectoryPath { get; } + public string RelativePath { get; } /// /// Gets the directory children of the node. diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs index ed9246612c..f9e1e4e164 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFileNode.cs @@ -18,7 +18,7 @@ public class PullRequestFileNode : ReactiveObject, IPullRequestFileNode /// Initializes a new instance of the class. /// /// The absolute path to the repository. - /// The path to the file, relative to the repository. + /// The path to the file, relative to the repository. /// The SHA of the file. /// The way the file was changed. /// The string to display in the [message] box next to the filename. @@ -28,17 +28,17 @@ public class PullRequestFileNode : ReactiveObject, IPullRequestFileNode /// public PullRequestFileNode( string repositoryPath, - string path, + string relativePath, string sha, PullRequestFileStatus status, string oldPath) { Guard.ArgumentNotEmptyString(repositoryPath, nameof(repositoryPath)); - Guard.ArgumentNotEmptyString(path, nameof(path)); + Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath)); Guard.ArgumentNotEmptyString(sha, nameof(sha)); - FileName = Path.GetFileName(path); - DirectoryPath = Path.GetDirectoryName(path); + FileName = Path.GetFileName(relativePath); + RelativePath = relativePath.Replace("/", "\\"); Sha = sha; Status = status; OldPath = oldPath; @@ -51,7 +51,7 @@ public PullRequestFileNode( { if (oldPath != null) { - StatusDisplay = Path.GetDirectoryName(oldPath) == Path.GetDirectoryName(path) ? + StatusDisplay = Path.GetDirectoryName(oldPath) == Path.GetDirectoryName(relativePath) ? Path.GetFileName(oldPath) : oldPath; } else @@ -67,9 +67,9 @@ public PullRequestFileNode( public string FileName { get; } /// - /// Gets the path to the file's directory, relative to the root of the repository. + /// Gets the path to the file, relative to the root of the repository. /// - public string DirectoryPath { get; } + public string RelativePath { get; } /// /// Gets the old path of a moved/renamed file, relative to the root of the repository. diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs new file mode 100644 index 0000000000..bad6947451 --- /dev/null +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModel.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Threading.Tasks; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; +using ReactiveUI; +using Task = System.Threading.Tasks.Task; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// View model displaying a tree of changed files in a pull request. + /// + [Export(typeof(IPullRequestFilesViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public sealed class PullRequestFilesViewModel : ViewModelBase, IPullRequestFilesViewModel + { + readonly IPullRequestService service; + readonly BehaviorSubject isBranchCheckedOut = new BehaviorSubject(false); + + IPullRequestSession pullRequestSession; + Func commentFilter; + int changedFilesCount; + IReadOnlyList items; + CompositeDisposable subscriptions; + + [ImportingConstructor] + public PullRequestFilesViewModel( + IPullRequestService service, + IPullRequestEditorService editorService) + { + Guard.ArgumentNotNull(service, nameof(service)); + Guard.ArgumentNotNull(editorService, nameof(editorService)); + + this.service = service; + + DiffFile = ReactiveCommand.CreateAsyncTask(x => + editorService.OpenDiff(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, false)); + ViewFile = ReactiveCommand.CreateAsyncTask(x => + editorService.OpenFile(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, false)); + DiffFileWithWorkingDirectory = ReactiveCommand.CreateAsyncTask( + isBranchCheckedOut, + x => editorService.OpenDiff(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, true)); + OpenFileInWorkingDirectory = ReactiveCommand.CreateAsyncTask( + isBranchCheckedOut, + x => editorService.OpenFile(pullRequestSession, ((IPullRequestFileNode)x).RelativePath, true)); + + OpenFirstComment = ReactiveCommand.CreateAsyncTask(async x => + { + var file = (IPullRequestFileNode)x; + var thread = await GetFirstCommentThread(file); + + if (thread != null) + { + await editorService.OpenDiff(pullRequestSession, file.RelativePath, thread); + } + }); + } + + /// + public int ChangedFilesCount + { + get { return changedFilesCount; } + private set { this.RaiseAndSetIfChanged(ref changedFilesCount, value); } + } + + /// + public IReadOnlyList Items + { + get { return items; } + private set { this.RaiseAndSetIfChanged(ref items, value); } + } + + /// + public void Dispose() + { + subscriptions?.Dispose(); + subscriptions = null; + } + + /// + public async Task InitializeAsync( + IPullRequestSession session, + Func filter = null) + { + Guard.ArgumentNotNull(session, nameof(session)); + + subscriptions?.Dispose(); + this.pullRequestSession = session; + this.commentFilter = filter; + subscriptions = new CompositeDisposable(); + subscriptions.Add(session.WhenAnyValue(x => x.IsCheckedOut).Subscribe(isBranchCheckedOut)); + + var dirs = new Dictionary + { + { string.Empty, new PullRequestDirectoryNode(string.Empty) } + }; + + using (var changes = await service.GetTreeChanges(session.LocalRepository, session.PullRequest)) + { + foreach (var changedFile in session.PullRequest.ChangedFiles) + { + var node = new PullRequestFileNode( + session.LocalRepository.LocalPath, + changedFile.FileName, + changedFile.Sha, + changedFile.Status, + GetOldFileName(changedFile, changes)); + var file = await session.GetFile(changedFile.FileName); + + if (file != null) + { + subscriptions.Add(file.WhenAnyValue(x => x.InlineCommentThreads) + .Subscribe(x => node.CommentCount = CountComments(x, filter))); + } + + var dir = GetDirectory(Path.GetDirectoryName(node.RelativePath), dirs); + dir.Files.Add(node); + } + } + + ChangedFilesCount = session.PullRequest.ChangedFiles.Count; + Items = dirs[string.Empty].Children.ToList(); + } + + /// + public ReactiveCommand DiffFile { get; } + + /// + public ReactiveCommand ViewFile { get; } + + /// + public ReactiveCommand DiffFileWithWorkingDirectory { get; } + + /// + public ReactiveCommand OpenFileInWorkingDirectory { get; } + + /// + public ReactiveCommand OpenFirstComment { get; } + + static int CountComments( + IEnumerable thread, + Func commentFilter) + { + return thread.Count(x => x.LineNumber != -1 && (commentFilter?.Invoke(x) ?? true)); + } + + static PullRequestDirectoryNode GetDirectory(string path, Dictionary dirs) + { + PullRequestDirectoryNode dir; + + if (!dirs.TryGetValue(path, out dir)) + { + var parentPath = Path.GetDirectoryName(path); + var parentDir = GetDirectory(parentPath, dirs); + + dir = new PullRequestDirectoryNode(path); + + if (!parentDir.Directories.Any(x => x.DirectoryName == dir.DirectoryName)) + { + parentDir.Directories.Add(dir); + dirs.Add(path, dir); + } + } + + return dir; + } + + static string GetOldFileName(IPullRequestFileModel file, TreeChanges changes) + { + if (file.Status == PullRequestFileStatus.Renamed) + { + var fileName = file.FileName.Replace("/", "\\"); + return changes?.Renamed.FirstOrDefault(x => x.Path == fileName)?.OldPath; + } + + return null; + } + + async Task GetFirstCommentThread(IPullRequestFileNode file) + { + var sessionFile = await pullRequestSession.GetFile(file.RelativePath); + var threads = sessionFile.InlineCommentThreads.AsEnumerable(); + + if (commentFilter != null) + { + threads = threads.Where(commentFilter); + } + + return threads.FirstOrDefault(); + } + } +} diff --git a/src/GitHub.App/packages.config b/src/GitHub.App/packages.config index dfe0b889c6..03a098bbfb 100644 --- a/src/GitHub.App/packages.config +++ b/src/GitHub.App/packages.config @@ -3,12 +3,23 @@ - + + + - - - + + + + + + + + + + + + diff --git a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj index 8b399b9220..f653adf5dc 100644 --- a/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj +++ b/src/GitHub.Exports.Reactive/GitHub.Exports.Reactive.csproj @@ -182,6 +182,7 @@ + @@ -194,6 +195,7 @@ + diff --git a/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs b/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs index 0f5f779c8c..b0371575af 100644 --- a/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs +++ b/src/GitHub.Exports.Reactive/Services/IPullRequestEditorService.cs @@ -1,9 +1,48 @@ -using Microsoft.VisualStudio.TextManager.Interop; +using System.Threading.Tasks; +using GitHub.Models; +using Microsoft.VisualStudio.TextManager.Interop; namespace GitHub.Services { + /// + /// Services for opening views of pull request files in Visual Studio. + /// public interface IPullRequestEditorService { + /// + /// Opens an editor for a file in a pull request. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// If true opens the file in the working directory, if false opens the file in the HEAD + /// commit of the pull request. + /// + /// A task tracking the operation. + Task OpenFile(IPullRequestSession session, string relativePath, bool workingDirectory); + + /// + /// Opens an diff viewer for a file in a pull request. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// + /// If true the right hand side of the diff will be the current state of the file in the + /// working directory, if false it will be the HEAD commit of the pull request. + /// + /// A task tracking the operation. + Task OpenDiff(IPullRequestSession session, string relativePath, bool workingDirectory); + + /// + /// Opens an diff viewer for a file in a pull request with the specified inline comment + /// thread open. + /// + /// The pull request session. + /// The path to the file, relative to the repository. + /// The thread to open + /// A task tracking the operation. + Task OpenDiff(IPullRequestSession session, string relativePath, IInlineCommentThreadModel thread); + /// /// Find the active text view. /// diff --git a/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs b/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs new file mode 100644 index 0000000000..4d8f1dca8f --- /dev/null +++ b/src/GitHub.Exports.Reactive/Services/PullRequestSessionExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using GitHub.Extensions; + +namespace GitHub.Services +{ + /// + /// Extension methods for . + /// + public static class PullRequestSessionExtensions + { + /// + /// Gets the head (source) branch label for a pull request, stripping the owner if the pull + /// request is not from a fork. + /// + /// The pull request session. + /// The head branch label + public static string GetHeadBranchDisplay(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + return GetBranchDisplay(session.IsPullRequestFromFork(), session.PullRequest?.Head?.Label); + } + + /// + /// Gets the head (target) branch label for a pull request, stripping the owner if the pull + /// request is not from a fork. + /// + /// The pull request session. + /// The head branch label + public static string GetBaseBranchDisplay(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + return GetBranchDisplay(session.IsPullRequestFromFork(), session.PullRequest?.Base?.Label); + } + + /// + /// Returns a value that determines whether the pull request comes from a fork. + /// + /// The pull request session. + /// True if the pull request is from a fork, otherwise false. + public static bool IsPullRequestFromFork(this IPullRequestSession session) + { + Guard.ArgumentNotNull(session, nameof(session)); + + var headUrl = session.PullRequest.Head.RepositoryCloneUrl?.ToRepositoryUrl(); + var localUrl = session.LocalRepository.CloneUrl?.ToRepositoryUrl(); + return headUrl != null && localUrl != null ? headUrl != localUrl : false; + } + + static string GetBranchDisplay(bool fork, string label) + { + if (label != null) + { + return fork ? label : label.Split(':').Last(); + } + + return "[invalid]"; + } + } +} diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs index 2661367609..a47f4889d1 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestChangeNode.cs @@ -8,9 +8,8 @@ namespace GitHub.ViewModels.GitHubPane public interface IPullRequestChangeNode { /// - /// Gets the path to the file (not including the filename) or directory, relative to the - /// root of the repository. + /// Gets the path to the file or directory, relative to the root of the repository. /// - string DirectoryPath { get; } + string RelativePath { get; } } } \ 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 078692d948..6d6a4262ac 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestDetailViewModel.cs @@ -126,9 +126,9 @@ public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowse string Body { get; } /// - /// Gets the changed files as a tree. + /// Gets the pull request's changed files. /// - IReadOnlyList ChangedFilesTree { get; } + IPullRequestFilesViewModel Files { get; } /// /// Gets the state associated with the command. @@ -165,27 +165,6 @@ public interface IPullRequestDetailViewModel : IPanePageViewModel, IOpenInBrowse /// ReactiveCommand OpenOnGitHub { get; } - /// - /// Gets a command that diffs an between BASE and HEAD. - /// - ReactiveCommand DiffFile { get; } - - /// - /// Gets a command that diffs an between the version in - /// the working directory and HEAD. - /// - ReactiveCommand DiffFileWithWorkingDirectory { get; } - - /// - /// Gets a command that opens an from disk. - /// - ReactiveCommand OpenFileInWorkingDirectory { get; } - - /// - /// Gets a command that opens an as it appears in the PR. - /// - ReactiveCommand ViewFile { get; } - /// /// Initializes the view model. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs new file mode 100644 index 0000000000..6953271341 --- /dev/null +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestFilesViewModel.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using LibGit2Sharp; +using ReactiveUI; + +namespace GitHub.ViewModels.GitHubPane +{ + /// + /// Represents a tree of changed files in a pull request. + /// + public interface IPullRequestFilesViewModel : IViewModel, IDisposable + { + /// + /// Gets the number of changed files in the pull request. + /// + int ChangedFilesCount { get; } + + /// + /// Gets the root nodes of the tree. + /// + IReadOnlyList Items { get; } + + /// + /// Gets a command that diffs an between BASE and HEAD. + /// + ReactiveCommand DiffFile { get; } + + /// + /// Gets a command that opens an as it appears in the PR. + /// + ReactiveCommand ViewFile { get; } + + /// + /// Gets a command that diffs an between the version in + /// the working directory and HEAD. + /// + ReactiveCommand DiffFileWithWorkingDirectory { get; } + + /// + /// Gets a command that opens an from disk. + /// + ReactiveCommand OpenFileInWorkingDirectory { get; } + + /// + /// Gets a command that opens the first comment for a in + /// the diff viewer. + /// + ReactiveCommand OpenFirstComment { get; } + + /// + /// Initializes the view model. + /// + /// The pull request session. + /// An optional review comment filter. + Task InitializeAsync( + IPullRequestSession session, + Func commentFilter = null); + } +} \ No newline at end of file diff --git a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj index 8f0a917e97..14a5784b4f 100644 --- a/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj +++ b/src/GitHub.VisualStudio/GitHub.VisualStudio.csproj @@ -374,6 +374,9 @@ GitHubPaneView.xaml + + PullRequestFilesView.xaml + PullRequestListView.xaml @@ -389,7 +392,6 @@ NotAGitRepositoryView.xaml - RepositoryPublishView.xaml @@ -527,6 +529,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + MSBuild:Compile Designer diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml index b5f1be0547..00976c6ee0 100644 --- a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml @@ -273,118 +273,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs index 038160144a..542a62b614 100644 --- a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestDetailView.xaml.cs @@ -1,32 +1,17 @@ using System; using System.ComponentModel.Composition; using System.Globalization; -using System.Linq; using System.Reactive.Linq; using System.Windows; -using System.Windows.Controls; -using System.Windows.Documents; using System.Windows.Input; -using System.Windows.Media; -using GitHub.Commands; using GitHub.Exports; using GitHub.Extensions; -using GitHub.Models; using GitHub.Services; using GitHub.UI; using GitHub.UI.Helpers; using GitHub.ViewModels.GitHubPane; using GitHub.VisualStudio.UI.Helpers; -using Microsoft.VisualStudio; -using Microsoft.VisualStudio.Editor; -using Microsoft.VisualStudio.Shell; -using Microsoft.VisualStudio.Shell.Interop; -using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Text.Editor; -using Microsoft.VisualStudio.Text.Projection; -using Microsoft.VisualStudio.TextManager.Interop; using ReactiveUI; -using Task = System.Threading.Tasks.Task; namespace GitHub.VisualStudio.Views.GitHubPane { @@ -47,38 +32,12 @@ public PullRequestDetailView() this.WhenActivated(d => { d(ViewModel.OpenOnGitHub.Subscribe(_ => DoOpenOnGitHub())); - d(ViewModel.DiffFile.Subscribe(x => DoDiffFile((IPullRequestFileNode)x, false).Forget())); - d(ViewModel.ViewFile.Subscribe(x => DoOpenFile((IPullRequestFileNode)x, false).Forget())); - d(ViewModel.DiffFileWithWorkingDirectory.Subscribe(x => DoDiffFile((IPullRequestFileNode)x, true).Forget())); - d(ViewModel.OpenFileInWorkingDirectory.Subscribe(x => DoOpenFile((IPullRequestFileNode)x, true).Forget())); }); - - bodyGrid.RequestBringIntoView += BodyFocusHack; } - [Import] - ITeamExplorerServiceHolder TeamExplorerServiceHolder { get; set; } - [Import] IVisualStudioBrowser VisualStudioBrowser { get; set; } - [Import] - IEditorOptionsFactoryService EditorOptionsFactoryService { get; set; } - - [Import] - IUsageTracker UsageTracker { get; set; } - - [Import] - IPullRequestEditorService NavigationService { get; set; } - - [Import] - IVsEditorAdaptersFactoryService EditorAdaptersFactoryService { get; set; } - - protected override void OnVisualParentChanged(DependencyObject oldParent) - { - base.OnVisualParentChanged(oldParent); - } - void DoOpenOnGitHub() { var browser = VisualStudioBrowser; @@ -93,315 +52,6 @@ static Uri ToPullRequestUrl(string host, string owner, string repositoryName, in return new Uri(url); } - async Task DoOpenFile(IPullRequestFileNode file, bool workingDirectory) - { - try - { - var fullPath = ViewModel.GetLocalFilePath(file); - var fileName = workingDirectory ? fullPath : await ViewModel.ExtractFile(file, true); - - using (workingDirectory ? null : OpenInProvisionalTab()) - { - var window = GitHub.VisualStudio.Services.Dte.ItemOperations.OpenFile(fileName); - window.Document.ReadOnly = !workingDirectory; - - var buffer = GetBufferAt(fileName); - - if (!workingDirectory) - { - AddBufferTag(buffer, ViewModel.Session, fullPath, null); - - var textView = NavigationService.FindActiveView(); - EnableNavigateToEditor(textView, file); - } - } - - if (workingDirectory) - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsOpenFileInSolution); - else - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewFile); - } - catch (Exception e) - { - ShowErrorInStatusBar("Error opening file", e); - } - } - - async Task DoNavigateToEditor(IPullRequestFileNode file) - { - try - { - if (!ViewModel.IsCheckedOut) - { - ShowInfoMessage("Checkout PR branch before opening file in solution."); - return; - } - - var fullPath = ViewModel.GetLocalFilePath(file); - - var activeView = NavigationService.FindActiveView(); - if (activeView == null) - { - ShowErrorInStatusBar("Couldn't find active view"); - return; - } - - NavigationService.NavigateToEquivalentPosition(activeView, fullPath); - - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsNavigateToEditor); - } - catch (Exception e) - { - ShowErrorInStatusBar("Error navigating to editor", e); - } - } - - static void ShowInfoMessage(string message) - { - ErrorHandler.ThrowOnFailure(VsShellUtilities.ShowMessageBox( - Services.GitHubServiceProvider, message, null, - OLEMSGICON.OLEMSGICON_INFO, OLEMSGBUTTON.OLEMSGBUTTON_OK, OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST)); - } - - async Task DoDiffFile(IPullRequestFileNode file, bool workingDirectory) - { - try - { - var rightPath = System.IO.Path.Combine(file.DirectoryPath, file.FileName); - var leftPath = file.OldPath ?? rightPath; - var rightFile = workingDirectory ? ViewModel.GetLocalFilePath(file) : await ViewModel.ExtractFile(file, true); - var leftFile = await ViewModel.ExtractFile(file, false); - var leftLabel = $"{leftPath};{ViewModel.TargetBranchDisplayName}"; - var rightLabel = workingDirectory ? rightPath : $"{rightPath};PR {ViewModel.Model.Number}"; - var caption = $"Diff - {file.FileName}"; - var options = __VSDIFFSERVICEOPTIONS.VSDIFFOPT_DetectBinaryFiles | - __VSDIFFSERVICEOPTIONS.VSDIFFOPT_LeftFileIsTemporary; - - if (!workingDirectory) - { - options |= __VSDIFFSERVICEOPTIONS.VSDIFFOPT_RightFileIsTemporary; - } - - IVsWindowFrame frame; - using (OpenInProvisionalTab()) - { - var tooltip = $"{leftLabel}\nvs.\n{rightLabel}"; - - // Diff window will open in provisional (right hand) tab until document is touched. - frame = GitHub.VisualStudio.Services.DifferenceService.OpenComparisonWindow2( - leftFile, - rightFile, - caption, - tooltip, - leftLabel, - rightLabel, - string.Empty, - string.Empty, - (uint)options); - } - - object docView; - frame.GetProperty((int)__VSFPROPID.VSFPROPID_DocView, out docView); - var diffViewer = ((IVsDifferenceCodeWindow)docView).DifferenceViewer; - - var session = ViewModel.Session; - AddBufferTag(diffViewer.LeftView.TextBuffer, session, leftPath, DiffSide.Left); - - if (!workingDirectory) - { - AddBufferTag(diffViewer.RightView.TextBuffer, session, rightPath, DiffSide.Right); - EnableNavigateToEditor(diffViewer.LeftView, file); - EnableNavigateToEditor(diffViewer.RightView, file); - EnableNavigateToEditor(diffViewer.InlineView, file); - } - - if (workingDirectory) - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsCompareWithSolution); - else - await UsageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewChanges); - } - catch (Exception e) - { - ShowErrorInStatusBar("Error opening file", e); - } - } - - void AddBufferTag(ITextBuffer buffer, IPullRequestSession session, string path, DiffSide? side) - { - buffer.Properties.GetOrCreateSingletonProperty( - typeof(PullRequestTextBufferInfo), - () => new PullRequestTextBufferInfo(session, path, side)); - - var projection = buffer as IProjectionBuffer; - - if (projection != null) - { - foreach (var source in projection.SourceBuffers) - { - AddBufferTag(source, session, path, side); - } - } - } - - void EnableNavigateToEditor(IWpfTextView textView, IPullRequestFileNode file) - { - var view = EditorAdaptersFactoryService.GetViewAdapter(textView); - EnableNavigateToEditor(view, file); - } - - void EnableNavigateToEditor(IVsTextView textView, IPullRequestFileNode file) - { - var commandGroup = VSConstants.CMDSETID.StandardCommandSet2K_guid; - var commandId = (int)VSConstants.VSStd2KCmdID.RETURN; - new TextViewCommandDispatcher(textView, commandGroup, commandId).Exec += async (s, e) => await DoNavigateToEditor(file); - - var contextMenuCommandGroup = new Guid(Guids.guidContextMenuSetString); - var goToCommandId = PkgCmdIDList.openFileInSolutionCommand; - new TextViewCommandDispatcher(textView, contextMenuCommandGroup, goToCommandId).Exec += async (s, e) => await DoNavigateToEditor(file); - } - - void ShowErrorInStatusBar(string message, Exception e = null) - { - var ns = GitHub.VisualStudio.Services.DefaultExportProvider.GetExportedValue(); - if (e != null) - { - message += ": " + e.Message; - } - ns?.ShowMessage(message); - } - - private void FileListKeyUp(object sender, KeyEventArgs e) - { - if (e.Key == Key.Return) - { - var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; - if (file != null) - { - DoDiffFile(file, false).Forget(); - } - } - } - - void FileListMouseDoubleClick(object sender, MouseButtonEventArgs e) - { - var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; - - if (file != null) - { - DoDiffFile(file, false).Forget(); - } - } - - void FileListMouseRightButtonDown(object sender, MouseButtonEventArgs e) - { - var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); - - if (item != null) - { - // Select tree view item on right click. - item.IsSelected = true; - } - } - - ITextBuffer GetBufferAt(string filePath) - { - var editorAdapterFactoryService = GitHub.VisualStudio.Services.ComponentModel.GetService(); - IVsUIHierarchy uiHierarchy; - uint itemID; - IVsWindowFrame windowFrame; - - if (VsShellUtilities.IsDocumentOpen( - GitHub.VisualStudio.Services.GitHubServiceProvider, - filePath, - Guid.Empty, - out uiHierarchy, - out itemID, - out windowFrame)) - { - IVsTextView view = VsShellUtilities.GetTextView(windowFrame); - IVsTextLines lines; - if (view.GetBuffer(out lines) == 0) - { - var buffer = lines as IVsTextBuffer; - if (buffer != null) - return editorAdapterFactoryService.GetDataBuffer(buffer); - } - } - - return null; - } - - void TreeView_ContextMenuOpening(object sender, ContextMenuEventArgs e) - { - ApplyContextMenuBinding(sender, e); - } - - void ApplyContextMenuBinding(object sender, ContextMenuEventArgs e) where TItem : Control - { - var container = (Control)sender; - var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); - - e.Handled = true; - - if (item != null) - { - var fileNode = item.DataContext as IPullRequestFileNode; - - if (fileNode != null) - { - container.ContextMenu.DataContext = this.DataContext; - - foreach (var menuItem in container.ContextMenu.Items.OfType()) - { - menuItem.CommandParameter = fileNode; - } - - e.Handled = false; - } - } - } - - void BodyFocusHack(object sender, RequestBringIntoViewEventArgs e) - { - if (e.TargetObject == bodyMarkdown) - { - // Hack to prevent pane scrolling to top. Instead focus selected tree view item. - // See https://github.com/github/VisualStudio/issues/1042 - var node = changesTree.GetTreeViewItem(changesTree.SelectedItem); - node?.Focus(); - e.Handled = true; - } - } - - async void ViewFileCommentsClick(object sender, RoutedEventArgs e) - { - try - { - var file = (e.OriginalSource as Hyperlink)?.DataContext as IPullRequestFileNode; - - if (file != null) - { - var param = (object)new InlineCommentNavigationParams - { - FromLine = -1, - }; - - await DoDiffFile(file, false); - - // HACK: We need to wait here for the diff view to set itself up and move its cursor - // to the first changed line. There must be a better way of doing this. - await Task.Delay(1500); - - GitHub.VisualStudio.Services.Dte.Commands.Raise( - Guids.CommandSetString, - PkgCmdIDList.NextInlineCommentId, - ref param, - null); - } - } - catch { } - } - void OpenHyperlink(object sender, ExecutedRoutedEventArgs e) { Uri uri; @@ -411,12 +61,5 @@ void OpenHyperlink(object sender, ExecutedRoutedEventArgs e) VisualStudioBrowser.OpenUrl(uri); } } - - static IDisposable OpenInProvisionalTab() - { - return new NewDocumentStateScope - (__VSNEWDOCUMENTSTATE.NDS_Provisional, - VSConstants.NewDocumentStateReason.SolutionExplorer); - } } } diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml new file mode 100644 index 0000000000..b344d21c01 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml.cs b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml.cs new file mode 100644 index 0000000000..2f2956ef45 --- /dev/null +++ b/src/GitHub.VisualStudio/Views/GitHubPane/PullRequestFilesView.xaml.cs @@ -0,0 +1,87 @@ +using System.ComponentModel.Composition; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using GitHub.Exports; +using GitHub.UI.Helpers; +using GitHub.ViewModels.GitHubPane; + +namespace GitHub.VisualStudio.Views.GitHubPane +{ + [ExportViewFor(typeof(IPullRequestFilesViewModel))] + [PartCreationPolicy(CreationPolicy.NonShared)] + public partial class PullRequestFilesView : UserControl + { + public PullRequestFilesView() + { + InitializeComponent(); + } + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + base.OnMouseDown(e); + } + + void changesTree_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + ApplyContextMenuBinding(sender, e); + } + + void changesTree_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; + (DataContext as IPullRequestFilesViewModel)?.DiffFile.Execute(file); + } + + void changesTree_MouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); + + if (item != null) + { + // Select tree view item on right click. + item.IsSelected = true; + } + } + + void ApplyContextMenuBinding(object sender, ContextMenuEventArgs e) where TItem : Control + { + var container = (Control)sender; + var item = (e.OriginalSource as Visual)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault(); + + e.Handled = true; + + if (item != null) + { + var fileNode = item.DataContext as IPullRequestFileNode; + + if (fileNode != null) + { + container.ContextMenu.DataContext = this.DataContext; + + foreach (var menuItem in container.ContextMenu.Items.OfType()) + { + menuItem.CommandParameter = fileNode; + } + + e.Handled = false; + } + } + } + + private void changesTree_KeyUp(object sender, KeyEventArgs e) + { + if (e.Key == Key.Return) + { + var file = (e.OriginalSource as FrameworkElement)?.DataContext as IPullRequestFileNode; + + if (file != null) + { + (DataContext as IPullRequestFilesViewModel)?.DiffFile.Execute(file); + } + } + } + } +} diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs index c9e409c7c1..a344f1499a 100644 --- a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestDetailViewModelTests.cs @@ -49,102 +49,6 @@ public async Task ShouldAcceptNullHead() } } - public class TheChangedFilesTreeProperty - { - [Test] - public async Task ShouldCreateChangesTree() - { - var target = CreateTarget(); - var pr = CreatePullRequest(); - - pr.ChangedFiles = new[] - { - new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir1/f1.cs", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir1/f2.cs", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir1/dir1a/f3.cs", "abc", PullRequestFileStatus.Modified), - new PullRequestFileModel("dir2/f4.cs", "abc", PullRequestFileStatus.Modified), - }; - - await target.Load(pr); - - Assert.That(3, Is.EqualTo(target.ChangedFilesTree.Count)); - - var dir1 = (PullRequestDirectoryNode)target.ChangedFilesTree[0]; - Assert.That("dir1", Is.EqualTo(dir1.DirectoryName)); - Assert.That(2, Is.EqualTo(dir1.Files.Count)); - Assert.That(1, Is.EqualTo(dir1.Directories.Count)); - Assert.That("f1.cs", Is.EqualTo(dir1.Files[0].FileName)); - Assert.That("f2.cs", Is.EqualTo(dir1.Files[1].FileName)); - Assert.That("dir1", Is.EqualTo(dir1.Files[0].DirectoryPath)); - Assert.That("dir1", Is.EqualTo(dir1.Files[1].DirectoryPath)); - - var dir1a = (PullRequestDirectoryNode)dir1.Directories[0]; - Assert.That("dir1a", Is.EqualTo(dir1a.DirectoryName)); - Assert.That(1, Is.EqualTo(dir1a.Files.Count)); - Assert.That(0, Is.EqualTo(dir1a.Directories.Count)); - - var dir2 = (PullRequestDirectoryNode)target.ChangedFilesTree[1]; - Assert.That("dir2", Is.EqualTo(dir2.DirectoryName)); - Assert.That(1, Is.EqualTo(dir2.Files.Count)); - Assert.That(0, Is.EqualTo(dir2.Directories.Count)); - - var readme = (PullRequestFileNode)target.ChangedFilesTree[2]; - Assert.That("readme.md", Is.EqualTo(readme.FileName)); - } - - [Test] - public async Task FileCommentCountShouldTrackSessionInlineComments() - { - var pr = CreatePullRequest(); - var file = Substitute.For(); - var thread1 = CreateThread(5); - var thread2 = CreateThread(6); - var outdatedThread = CreateThread(-1); - var session = Substitute.For(); - var sessionManager = Substitute.For(); - - file.InlineCommentThreads.Returns(new[] { thread1 }); - session.GetFile("readme.md").Returns(Task.FromResult(file)); - sessionManager.GetSession(pr).Returns(Task.FromResult(session)); - - var target = CreateTarget(sessionManager: sessionManager); - - pr.ChangedFiles = new[] - { - new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), - }; - - await target.Load(pr); - Assert.That(1, Is.EqualTo(((IPullRequestFileNode)target.ChangedFilesTree[0]).CommentCount)); - - file.InlineCommentThreads.Returns(new[] { thread1, thread2 }); - RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); - Assert.That(2, Is.EqualTo(((IPullRequestFileNode)target.ChangedFilesTree[0]).CommentCount)); - - // Outdated comment is not included in the count. - file.InlineCommentThreads.Returns(new[] { thread1, thread2, outdatedThread }); - RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); - Assert.That(2, Is.EqualTo(((IPullRequestFileNode)target.ChangedFilesTree[0]).CommentCount)); - - file.Received(1).PropertyChanged += Arg.Any(); - } - - IInlineCommentThreadModel CreateThread(int lineNumber) - { - var result = Substitute.For(); - result.LineNumber.Returns(lineNumber); - return result; - } - - void RaisePropertyChanged(T o, string propertyName) - where T : INotifyPropertyChanged - { - o.PropertyChanged += Raise.Event(new PropertyChangedEventArgs(propertyName)); - } - - } - public class TheCheckoutCommand { [Test] @@ -556,7 +460,8 @@ static Tuple CreateTargetAndSer Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), + Substitute.For()); vm.InitializeAsync(repository, Substitute.For(), "owner", "repo", 1).Wait(); return Tuple.Create(vm, pullRequestService); diff --git a/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModelTests.cs b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModelTests.cs new file mode 100644 index 0000000000..67a6cf87e6 --- /dev/null +++ b/test/UnitTests/GitHub.App/ViewModels/GitHubPane/PullRequestFilesViewModelTests.cs @@ -0,0 +1,128 @@ +using System; +using System.ComponentModel; +using System.Reactive.Linq; +using System.Threading.Tasks; +using GitHub.Models; +using GitHub.Services; +using GitHub.ViewModels.GitHubPane; +using NSubstitute; +using NUnit.Framework; + +namespace UnitTests.GitHub.App.ViewModels.GitHubPane +{ + public class PullRequestFilesViewModelTests + { + static readonly Uri Uri = new Uri("http://foo"); + + [Test] + public async Task ShouldCreateChangesTree() + { + var target = CreateTarget(); + var session = CreateSession(); + + session.PullRequest.ChangedFiles.Returns(new[] + { + new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir1/f1.cs", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir1/f2.cs", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir1/dir1a/f3.cs", "abc", PullRequestFileStatus.Modified), + new PullRequestFileModel("dir2/f4.cs", "abc", PullRequestFileStatus.Modified), + }); + + await target.InitializeAsync(session); + + Assert.That(target.Items.Count, Is.EqualTo(3)); + + var dir1 = (PullRequestDirectoryNode)target.Items[0]; + Assert.That(dir1.DirectoryName, Is.EqualTo("dir1")); + Assert.That(dir1.Files, Has.Exactly(2).Items); + + Assert.That(dir1.Directories, Has.One.Items); + Assert.That(dir1.Files[0].FileName, Is.EqualTo("f1.cs")); + Assert.That(dir1.Files[1].FileName, Is.EqualTo("f2.cs")); + Assert.That(dir1.Files[0].RelativePath, Is.EqualTo("dir1\\f1.cs")); + Assert.That(dir1.Files[1].RelativePath, Is.EqualTo("dir1\\f2.cs")); + + var dir1a = (PullRequestDirectoryNode)dir1.Directories[0]; + Assert.That(dir1a.DirectoryName, Is.EqualTo("dir1a")); + Assert.That(dir1a.Files, Has.One.Items); + Assert.That(dir1a.Directories, Is.Empty); + + var dir2 = (PullRequestDirectoryNode)target.Items[1]; + Assert.That(dir2.DirectoryName, Is.EqualTo("dir2")); + Assert.That(dir2.Files, Has.One.Items); + Assert.That(dir2.Directories, Is.Empty); + + var readme = (PullRequestFileNode)target.Items[2]; + Assert.That(readme.FileName, Is.EqualTo("readme.md")); + } + + [Test] + public async Task FileCommentCountShouldTrackSessionInlineComments() + { + var outdatedThread = CreateThread(-1); + var session = CreateSession(); + + session.PullRequest.ChangedFiles.Returns(new[] + { + new PullRequestFileModel("readme.md", "abc", PullRequestFileStatus.Modified), + }); + + var file = Substitute.For(); + var thread1 = CreateThread(5); + var thread2 = CreateThread(6); + file.InlineCommentThreads.Returns(new[] { thread1 }); + session.GetFile("readme.md").Returns(Task.FromResult(file)); + + var target = CreateTarget(); + + await target.InitializeAsync(session); + Assert.That(((IPullRequestFileNode)target.Items[0]).CommentCount, Is.EqualTo(1)); + + file.InlineCommentThreads.Returns(new[] { thread1, thread2 }); + RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); + Assert.That(((IPullRequestFileNode)target.Items[0]).CommentCount, Is.EqualTo(2)); + + // Outdated comment is not included in the count. + file.InlineCommentThreads.Returns(new[] { thread1, thread2, outdatedThread }); + RaisePropertyChanged(file, nameof(file.InlineCommentThreads)); + Assert.That(((IPullRequestFileNode)target.Items[0]).CommentCount, Is.EqualTo(2)); + + file.Received(1).PropertyChanged += Arg.Any(); + } + + static PullRequestFilesViewModel CreateTarget() + { + var pullRequestService = Substitute.For(); + var editorService = Substitute.For(); + return new PullRequestFilesViewModel(pullRequestService, editorService); + } + + static IPullRequestSession CreateSession() + { + var author = Substitute.For(); + var pr = Substitute.For(); + + var repository = Substitute.For(); + repository.LocalPath.Returns(@"C:\Foo"); + + var result = Substitute.For(); + result.LocalRepository.Returns(repository); + result.PullRequest.Returns(pr); + return result; + } + + IInlineCommentThreadModel CreateThread(int lineNumber) + { + var result = Substitute.For(); + result.LineNumber.Returns(lineNumber); + return result; + } + + void RaisePropertyChanged(T o, string propertyName) + where T : INotifyPropertyChanged + { + o.PropertyChanged += Raise.Event(new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs b/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs index acbc202559..51838e4f11 100644 --- a/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs +++ b/test/UnitTests/GitHub.Exports.Reactive/Services/PullRequestEditorServiceTests.cs @@ -2,6 +2,7 @@ using GitHub.Services; using NUnit.Framework; using NSubstitute; +using Microsoft.VisualStudio.Editor; public class PullRequestEditorServiceTests { @@ -48,6 +49,15 @@ public void FindNearestMatchingLine(IList fromLines, IList toLin static PullRequestEditorService CreateNavigationService() { var sp = Substitute.For(); - return new PullRequestEditorService(sp); + var pullRequestService = Substitute.For(); + var vsEditorAdaptersFactory = Substitute.For(); + var statusBar = Substitute.For(); + var usageTracker = Substitute.For(); + return new PullRequestEditorService( + sp, + pullRequestService, + vsEditorAdaptersFactory, + statusBar, + usageTracker); } } diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 6426dc4779..164350fce0 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -82,6 +82,10 @@ ..\..\packages\Microsoft.VisualStudio.CoreUtility.14.3.25407\lib\net45\Microsoft.VisualStudio.CoreUtility.dll True + + ..\..\packages\Microsoft.VisualStudio.Editor.14.3.25407\lib\net45\Microsoft.VisualStudio.Editor.dll + True + ..\..\packages\Microsoft.VisualStudio.Language.Intellisense.14.3.25407\lib\net45\Microsoft.VisualStudio.Language.Intellisense.dll True @@ -240,6 +244,7 @@ + diff --git a/test/UnitTests/packages.config b/test/UnitTests/packages.config index a80e0e8c78..f6e22cda65 100644 --- a/test/UnitTests/packages.config +++ b/test/UnitTests/packages.config @@ -6,6 +6,7 @@ +