From 3081e24dac4a6a7df57e03bd9a3fc6c0778c582b Mon Sep 17 00:00:00 2001 From: Jamie Cansdale Date: Tue, 17 Jul 2018 12:42:20 +0100 Subject: [PATCH 1/2] Enable navigation from historical file to solution Extend the GoToSolutionOrPullRequestFile command to support navigation from a file opened using the history view to the same file in the users solution/working directory. --- .../Services/GitHubContextService.cs | 51 ++++++++ .../Services/IGitHubContextService.cs | 24 ++++ .../GoToSolutionOrPullRequestFileCommand.cs | 93 +++++++++++++++ .../Services/GitHubContextServiceTests.cs | 112 ++++++++++++++++++ 4 files changed, 280 insertions(+) diff --git a/src/GitHub.App/Services/GitHubContextService.cs b/src/GitHub.App/Services/GitHubContextService.cs index 20dce6933f..640e85a659 100644 --- a/src/GitHub.App/Services/GitHubContextService.cs +++ b/src/GitHub.App/Services/GitHubContextService.cs @@ -58,6 +58,8 @@ public class GitHubContextService : IGitHubContextService static readonly Regex treeishCommitRegex = new Regex($"(?[a-z0-9]{{40}})(/(?.+))?", RegexOptions.Compiled); static readonly Regex treeishBranchRegex = new Regex($"(?master)(/(?.+))?", RegexOptions.Compiled); + static readonly Regex tempFileObjectishRegex = new Regex(@"\\TFSTemp\\[^\\]*[.](?[a-z0-9]{8})[.][^.\\]*$", RegexOptions.Compiled); + [ImportingConstructor] public GitHubContextService(IGitHubServiceProvider serviceProvider, IGitService gitService) { @@ -305,6 +307,55 @@ public bool TryOpenFile(string repositoryDir, GitHubContext context) } } + /// + public string FindObjectishForTFSTempFile(string tempFile) + { + var match = tempFileObjectishRegex.Match(tempFile); + if (match.Success) + { + return match.Groups["objectish"].Value; + } + + return null; + } + + /// + public (string commitSha, string blobPath) ResolveBlobFromHistory(string repositoryDir, string objectish) + { + using (var repo = gitService.GetRepository(repositoryDir)) + { + var blob = repo.Lookup(objectish); + if (blob == null) + { + return (null, null); + } + + foreach (var commit in repo.Commits) + { + var trees = new Stack(); + trees.Push(commit.Tree); + + while (trees.Count > 0) + { + foreach (var treeEntry in trees.Pop()) + { + if (treeEntry.Target == blob) + { + return (commit.Sha, treeEntry.Path); + } + + if (treeEntry.TargetType == TreeEntryTargetType.Tree) + { + trees.Push((Tree)treeEntry.Target); + } + } + } + } + + return (null, null); + } + } + /// public bool HasChangesInWorkingDirectory(string repositoryDir, string commitish, string path) { diff --git a/src/GitHub.Exports/Services/IGitHubContextService.cs b/src/GitHub.Exports/Services/IGitHubContextService.cs index 9cc1767bdc..b8522e93a1 100644 --- a/src/GitHub.Exports/Services/IGitHubContextService.cs +++ b/src/GitHub.Exports/Services/IGitHubContextService.cs @@ -71,6 +71,30 @@ public interface IGitHubContextService /// The resolved commit-ish, blob path and commit SHA for the blob. Path will be null if the commit-ish can be resolved but not the blob. (string commitish, string path, string commitSha) ResolveBlob(string repositoryDir, GitHubContext context, string remoteName = "origin"); + /// + /// Find the object-ish (first 8 chars of a blob SHA) from the path to historical blob created by Team Explorer. + /// + /// + /// Team Explorer creates temporary blob files in the following format: + /// C:\Users\me\AppData\Local\Temp\TFSTemp\vctmp21996_181282.IOpenFromClipboardCommand.783ac965.cs + /// The object-ish appears immediately before the file extension and the path contains the folder "TFSTemp". + /// + /// The path to a possible Team Explorer temporary blob file. + /// The target file's object-ish (blob SHA fragment) or null if the path isn't recognized as a Team Explorer blob file. + string FindObjectishForTFSTempFile(string tempFile); + + /// + /// Find a tree entry in the commit log where a blob appears and return its commit SHA and path. + /// + /// + /// Search back through the commit log for the first tree entry where a blob appears. This operation only takes + /// a fraction of a seond on the `github/VisualStudio` repository even if a tree entry casn't be found. + /// + /// The target repository directory. + /// The fragment of a blob SHA to find. + /// The commit SHA and blob path or null if the blob can't be found. + (string commitSha, string blobPath) ResolveBlobFromHistory(string repositoryDir, string objectish); + /// /// Check if a file in the working directory has changed since a specified commit-ish. /// diff --git a/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs b/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs index 1089501897..846a97dd04 100644 --- a/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs +++ b/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.ComponentModel.Composition; using GitHub.Services; using GitHub.Extensions; @@ -10,6 +11,7 @@ using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Differencing; +using Microsoft.VisualStudio.TextManager.Interop; using Task = System.Threading.Tasks.Task; namespace GitHub.Commands @@ -40,6 +42,8 @@ public class GoToSolutionOrPullRequestFileCommand : VsCommand, IGoToSolutionOrPu readonly Lazy editorAdapter; readonly Lazy sessionManager; readonly Lazy pullRequestEditorService; + readonly Lazy teamExplorerContext; + readonly Lazy gitHubContextService; readonly Lazy statusBar; readonly Lazy usageTracker; @@ -49,6 +53,8 @@ public GoToSolutionOrPullRequestFileCommand( Lazy editorAdapter, Lazy sessionManager, Lazy pullRequestEditorService, + Lazy teamExplorerContext, + Lazy gitHubContextService, Lazy statusBar, Lazy usageTracker) : base(CommandSet, CommandId) { @@ -56,6 +62,8 @@ public GoToSolutionOrPullRequestFileCommand( this.editorAdapter = editorAdapter; this.sessionManager = sessionManager; this.pullRequestEditorService = pullRequestEditorService; + this.gitHubContextService = gitHubContextService; + this.teamExplorerContext = teamExplorerContext; this.statusBar = statusBar; this.usageTracker = usageTracker; @@ -112,6 +120,11 @@ public override async Task Execute() return; } + if (TryNavigateFromHistoryFile(sourceView)) + { + return; + } + var relativePath = sessionManager.Value.GetRelativePath(textView.TextBuffer); if (relativePath == null) { @@ -189,6 +202,11 @@ void OnBeforeQueryStatus(object sender, EventArgs e) return; } } + + if (TryNavigateFromHistoryFileQueryStatus(sourceView)) + { + return; + } } catch (Exception ex) { @@ -198,6 +216,81 @@ void OnBeforeQueryStatus(object sender, EventArgs e) Visible = false; } + bool TryNavigateFromHistoryFileQueryStatus(IVsTextView sourceView) + { + if (teamExplorerContext.Value.ActiveRepository?.LocalPath == null) + { + // Only available when there's an active repository + return false; + } + + var filePath = FindPath(sourceView); + if (filePath == null) + { + return false; + } + + var objectish = gitHubContextService.Value.FindObjectishForTFSTempFile(filePath); + if (objectish == null) + { + // Not a temporary Team Explorer blob file + return false; + } + + // Navigate from history file is active + Text = "Open File in Solution"; + Visible = true; + return true; + } + + bool TryNavigateFromHistoryFile(IVsTextView sourceView) + { + var repositoryDir = teamExplorerContext.Value.ActiveRepository?.LocalPath; + if (repositoryDir == null) + { + return false; + } + + var path = FindPath(sourceView); + if (path == null) + { + return false; + } + + var objectish = gitHubContextService.Value.FindObjectishForTFSTempFile(path); + if (objectish == null) + { + return false; + } + + var (commitSha, blobPath) = gitHubContextService.Value.ResolveBlobFromHistory(repositoryDir, objectish); + if (blobPath == null) + { + return false; + } + + var workingFile = Path.Combine(repositoryDir, blobPath); + VsShellUtilities.OpenDocument(serviceProvider, workingFile, VSConstants.LOGVIEWID.TextView_guid, + out IVsUIHierarchy hierarchy, out uint itemID, out IVsWindowFrame windowFrame, out IVsTextView targetView); + + pullRequestEditorService.Value.NavigateToEquivalentPosition(sourceView, targetView); + return true; + } + + // See http://microsoft.public.vstudio.extensibility.narkive.com/agfoD1GO/full-pathname-of-file-shown-in-current-view-of-core-editor#post2 + static string FindPath(IVsTextView textView) + { + ErrorHandler.ThrowOnFailure(textView.GetBuffer(out IVsTextLines buffer)); + var userData = buffer as IVsUserData; + if (userData == null) + { + return null; + } + + ErrorHandler.ThrowOnFailure(userData.GetData(typeof(IVsUserData).GUID, out object data)); + return data as string; + } + ITextView FindActiveTextView(IDifferenceViewer diffViewer) { switch (diffViewer.ActiveViewType) diff --git a/test/GitHub.App.UnitTests/Services/GitHubContextServiceTests.cs b/test/GitHub.App.UnitTests/Services/GitHubContextServiceTests.cs index 8cd83d5927..01fc6ba496 100644 --- a/test/GitHub.App.UnitTests/Services/GitHubContextServiceTests.cs +++ b/test/GitHub.App.UnitTests/Services/GitHubContextServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using GitHub.Exports; using GitHub.Services; using NSubstitute; @@ -382,6 +383,117 @@ public void ResolveBlob(string url, string commitish, string objectish, string e } } + public class TheResolveBlobFromCommitsMethod + { + [Test] + public void FlatTree() + { + var objectish = "12345678"; + var expectCommitSha = "2434215c5489db2bfa2e5249144a3bc532465f97"; + var expectBlobPath = "Class1.cs"; + var repositoryDir = "repositoryDir"; + var blob = Substitute.For(); + var treeEntry = CreateTreeEntry(TreeEntryTargetType.Blob, blob, expectBlobPath); + var commit = CreateCommit(expectCommitSha, treeEntry); + var repository = CreateRepository(commit); + repository.Lookup(objectish).Returns(blob); + var target = CreateGitHubContextService(repositoryDir, repository); + + var (commitSha, blobPath) = target.ResolveBlobFromHistory(repositoryDir, objectish); + + Assert.That((commitSha, blobPath), Is.EqualTo((expectCommitSha, expectBlobPath))); + } + + [Test] + public void NestedTree() + { + var objectish = "12345678"; + var expectCommitSha = "2434215c5489db2bfa2e5249144a3bc532465f97"; + var expectBlobPath = @"AnnotateFileTests\Class1.cs"; + var repositoryDir = "repositoryDir"; + var blob = Substitute.For(); + var blobTreeEntry = CreateTreeEntry(TreeEntryTargetType.Blob, blob, expectBlobPath); + var childTree = CreateTree(blobTreeEntry); + var treeTreeEntry = CreateTreeEntry(TreeEntryTargetType.Tree, childTree, "AnnotateFileTests"); + var commit = CreateCommit(expectCommitSha, treeTreeEntry); + var repository = CreateRepository(commit); + repository.Lookup(objectish).Returns(blob); + var target = CreateGitHubContextService(repositoryDir, repository); + + var (commitSha, blobPath) = target.ResolveBlobFromHistory(repositoryDir, objectish); + + Assert.That((commitSha, blobPath), Is.EqualTo((expectCommitSha, expectBlobPath))); + } + + [Test] + public void MissingBlob() + { + var objectish = "12345678"; + var repositoryDir = "repositoryDir"; + var treeEntry = Substitute.For(); + var repository = CreateRepository(); + var target = CreateGitHubContextService(repositoryDir, repository); + + var (commitSha, blobPath) = target.ResolveBlobFromHistory(repositoryDir, objectish); + + Assert.That((commitSha, blobPath), Is.EqualTo((null as string, null as string))); + } + + static IRepository CreateRepository(params Commit[] commits) + { + var repository = Substitute.For(); + var enumerator = commits.ToList().GetEnumerator(); + repository.Commits.GetEnumerator().Returns(enumerator); + return repository; + } + + static Commit CreateCommit(string sha, params TreeEntry[] treeEntries) + { + var commit = Substitute.For(); + commit.Sha.Returns(sha); + var tree = CreateTree(treeEntries); + commit.Tree.Returns(tree); + return commit; + } + + static TreeEntry CreateTreeEntry(TreeEntryTargetType targetType, GitObject target, string path) + { + var treeEntry = Substitute.For(); + treeEntry.TargetType.Returns(targetType); + treeEntry.Target.Returns(target); + treeEntry.Path.Returns(path); + return treeEntry; + } + + static Tree CreateTree(params TreeEntry[] treeEntries) + { + var tree = Substitute.For(); + var enumerator = treeEntries.ToList().GetEnumerator(); + tree.GetEnumerator().Returns(enumerator); + return tree; + } + } + + public class TheFindBlobShaForTextViewMethod + { + [TestCase(@"C:\Users\me\AppData\Local\Temp\TFSTemp\vctmp21996_181282.IOpenFromClipboardCommand.783ac965.cs", "783ac965")] + [TestCase(@"\TFSTemp\File.12345678.ext", "12345678")] + [TestCase(@"\TFSTemp\File.abcdefab.ext", "abcdefab")] + [TestCase(@"\TFSTemp\.12345678.", "12345678")] + [TestCase(@"\TFSTemp\File.ABCDEFAB.ext", null)] + [TestCase(@"\TFSTemp\File.1234567.ext", null)] + [TestCase(@"\TFSTemp\File.123456789.ext", null)] + [TestCase(@"\TFSTemp\File.12345678.ext\\", null)] + public void FindObjectishForTFSTempFile(string path, string expectObjectish) + { + var target = CreateGitHubContextService(); + + var objectish = target.FindObjectishForTFSTempFile(path); + + Assert.That(objectish, Is.EqualTo(expectObjectish)); + } + } + static GitHubContextService CreateGitHubContextService(string repositoryDir = null, IRepository repository = null) { var sp = Substitute.For(); From 92f82989f9ff5fb1165d555bb715969c3e8be9fe Mon Sep 17 00:00:00 2001 From: Jamie Cansdale Date: Fri, 27 Jul 2018 16:50:36 +0100 Subject: [PATCH 2/2] Refactored to make more readable Factored out FindObjectishForTFSTempFile and added comments. --- .../GoToSolutionOrPullRequestFileCommand.cs | 74 ++++++++----------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs b/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs index 846a97dd04..0ec4ed1bfd 100644 --- a/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs +++ b/src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs @@ -216,65 +216,49 @@ void OnBeforeQueryStatus(object sender, EventArgs e) Visible = false; } + // Set command Text/Visible properties and return true when active bool TryNavigateFromHistoryFileQueryStatus(IVsTextView sourceView) { - if (teamExplorerContext.Value.ActiveRepository?.LocalPath == null) + if (teamExplorerContext.Value.ActiveRepository?.LocalPath is string && // Check there is an active repo + FindObjectishForTFSTempFile(sourceView) is string) // Looks like a history file { - // Only available when there's an active repository - return false; + // Navigate from history file is active + Text = "Open File in Solution"; + Visible = true; + return true; } - var filePath = FindPath(sourceView); - if (filePath == null) - { - return false; - } - - var objectish = gitHubContextService.Value.FindObjectishForTFSTempFile(filePath); - if (objectish == null) - { - // Not a temporary Team Explorer blob file - return false; - } - - // Navigate from history file is active - Text = "Open File in Solution"; - Visible = true; - return true; + return false; } + // Attempt navigation to historical file bool TryNavigateFromHistoryFile(IVsTextView sourceView) { - var repositoryDir = teamExplorerContext.Value.ActiveRepository?.LocalPath; - if (repositoryDir == null) - { - return false; - } - - var path = FindPath(sourceView); - if (path == null) + if (teamExplorerContext.Value.ActiveRepository?.LocalPath is string repositoryDir && + FindObjectishForTFSTempFile(sourceView) is string objectish) { - return false; - } - - var objectish = gitHubContextService.Value.FindObjectishForTFSTempFile(path); - if (objectish == null) - { - return false; - } + var (commitSha, blobPath) = gitHubContextService.Value.ResolveBlobFromHistory(repositoryDir, objectish); + if (blobPath is string) + { + var workingFile = Path.Combine(repositoryDir, blobPath); + VsShellUtilities.OpenDocument(serviceProvider, workingFile, VSConstants.LOGVIEWID.TextView_guid, + out IVsUIHierarchy hierarchy, out uint itemID, out IVsWindowFrame windowFrame, out IVsTextView targetView); - var (commitSha, blobPath) = gitHubContextService.Value.ResolveBlobFromHistory(repositoryDir, objectish); - if (blobPath == null) - { - return false; + pullRequestEditorService.Value.NavigateToEquivalentPosition(sourceView, targetView); + return true; + } } - var workingFile = Path.Combine(repositoryDir, blobPath); - VsShellUtilities.OpenDocument(serviceProvider, workingFile, VSConstants.LOGVIEWID.TextView_guid, - out IVsUIHierarchy hierarchy, out uint itemID, out IVsWindowFrame windowFrame, out IVsTextView targetView); + return false; + } - pullRequestEditorService.Value.NavigateToEquivalentPosition(sourceView, targetView); - return true; + // Find the blob SHA in a file name if any + string FindObjectishForTFSTempFile(IVsTextView sourceView) + { + return + FindPath(sourceView) is string path && + gitHubContextService.Value.FindObjectishForTFSTempFile(path) is string objectish ? + objectish : null; } // See http://microsoft.public.vstudio.extensibility.narkive.com/agfoD1GO/full-pathname-of-file-shown-in-current-view-of-core-editor#post2