Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Commit 3081e24

Browse files
committed
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.
1 parent 49ad951 commit 3081e24

File tree

4 files changed

+280
-0
lines changed

4 files changed

+280
-0
lines changed

src/GitHub.App/Services/GitHubContextService.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public class GitHubContextService : IGitHubContextService
5858
static readonly Regex treeishCommitRegex = new Regex($"(?<commit>[a-z0-9]{{40}})(/(?<tree>.+))?", RegexOptions.Compiled);
5959
static readonly Regex treeishBranchRegex = new Regex($"(?<branch>master)(/(?<tree>.+))?", RegexOptions.Compiled);
6060

61+
static readonly Regex tempFileObjectishRegex = new Regex(@"\\TFSTemp\\[^\\]*[.](?<objectish>[a-z0-9]{8})[.][^.\\]*$", RegexOptions.Compiled);
62+
6163
[ImportingConstructor]
6264
public GitHubContextService(IGitHubServiceProvider serviceProvider, IGitService gitService)
6365
{
@@ -305,6 +307,55 @@ public bool TryOpenFile(string repositoryDir, GitHubContext context)
305307
}
306308
}
307309

310+
/// <inheritdoc/>
311+
public string FindObjectishForTFSTempFile(string tempFile)
312+
{
313+
var match = tempFileObjectishRegex.Match(tempFile);
314+
if (match.Success)
315+
{
316+
return match.Groups["objectish"].Value;
317+
}
318+
319+
return null;
320+
}
321+
322+
/// <inheritdoc/>
323+
public (string commitSha, string blobPath) ResolveBlobFromHistory(string repositoryDir, string objectish)
324+
{
325+
using (var repo = gitService.GetRepository(repositoryDir))
326+
{
327+
var blob = repo.Lookup<Blob>(objectish);
328+
if (blob == null)
329+
{
330+
return (null, null);
331+
}
332+
333+
foreach (var commit in repo.Commits)
334+
{
335+
var trees = new Stack<Tree>();
336+
trees.Push(commit.Tree);
337+
338+
while (trees.Count > 0)
339+
{
340+
foreach (var treeEntry in trees.Pop())
341+
{
342+
if (treeEntry.Target == blob)
343+
{
344+
return (commit.Sha, treeEntry.Path);
345+
}
346+
347+
if (treeEntry.TargetType == TreeEntryTargetType.Tree)
348+
{
349+
trees.Push((Tree)treeEntry.Target);
350+
}
351+
}
352+
}
353+
}
354+
355+
return (null, null);
356+
}
357+
}
358+
308359
/// <inheritdoc/>
309360
public bool HasChangesInWorkingDirectory(string repositoryDir, string commitish, string path)
310361
{

src/GitHub.Exports/Services/IGitHubContextService.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,30 @@ public interface IGitHubContextService
7171
/// <returns>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.</returns>
7272
(string commitish, string path, string commitSha) ResolveBlob(string repositoryDir, GitHubContext context, string remoteName = "origin");
7373

74+
/// <summary>
75+
/// Find the object-ish (first 8 chars of a blob SHA) from the path to historical blob created by Team Explorer.
76+
/// </summary>
77+
/// <remarks>
78+
/// Team Explorer creates temporary blob files in the following format:
79+
/// C:\Users\me\AppData\Local\Temp\TFSTemp\vctmp21996_181282.IOpenFromClipboardCommand.783ac965.cs
80+
/// The object-ish appears immediately before the file extension and the path contains the folder "TFSTemp".
81+
/// <remarks>
82+
/// <param name="tempFile">The path to a possible Team Explorer temporary blob file.</param>
83+
/// <returns>The target file's object-ish (blob SHA fragment) or null if the path isn't recognized as a Team Explorer blob file.</returns>
84+
string FindObjectishForTFSTempFile(string tempFile);
85+
86+
/// <summary>
87+
/// Find a tree entry in the commit log where a blob appears and return its commit SHA and path.
88+
/// </summary>
89+
/// <remarks>
90+
/// Search back through the commit log for the first tree entry where a blob appears. This operation only takes
91+
/// a fraction of a seond on the `github/VisualStudio` repository even if a tree entry casn't be found.
92+
/// </remarks>
93+
/// <param name="repositoryDir">The target repository directory.</param>
94+
/// <param name="objectish">The fragment of a blob SHA to find.</param>
95+
/// <returns>The commit SHA and blob path or null if the blob can't be found.</returns>
96+
(string commitSha, string blobPath) ResolveBlobFromHistory(string repositoryDir, string objectish);
97+
7498
/// <summary>
7599
/// Check if a file in the working directory has changed since a specified commit-ish.
76100
/// </summary>

src/GitHub.VisualStudio/Commands/GoToSolutionOrPullRequestFileCommand.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.IO;
23
using System.ComponentModel.Composition;
34
using GitHub.Services;
45
using GitHub.Extensions;
@@ -10,6 +11,7 @@
1011
using Microsoft.VisualStudio.Shell.Interop;
1112
using Microsoft.VisualStudio.Text.Editor;
1213
using Microsoft.VisualStudio.Text.Differencing;
14+
using Microsoft.VisualStudio.TextManager.Interop;
1315
using Task = System.Threading.Tasks.Task;
1416

1517
namespace GitHub.Commands
@@ -40,6 +42,8 @@ public class GoToSolutionOrPullRequestFileCommand : VsCommand, IGoToSolutionOrPu
4042
readonly Lazy<IVsEditorAdaptersFactoryService> editorAdapter;
4143
readonly Lazy<IPullRequestSessionManager> sessionManager;
4244
readonly Lazy<IPullRequestEditorService> pullRequestEditorService;
45+
readonly Lazy<ITeamExplorerContext> teamExplorerContext;
46+
readonly Lazy<IGitHubContextService> gitHubContextService;
4347
readonly Lazy<IStatusBarNotificationService> statusBar;
4448
readonly Lazy<IUsageTracker> usageTracker;
4549

@@ -49,13 +53,17 @@ public GoToSolutionOrPullRequestFileCommand(
4953
Lazy<IVsEditorAdaptersFactoryService> editorAdapter,
5054
Lazy<IPullRequestSessionManager> sessionManager,
5155
Lazy<IPullRequestEditorService> pullRequestEditorService,
56+
Lazy<ITeamExplorerContext> teamExplorerContext,
57+
Lazy<IGitHubContextService> gitHubContextService,
5258
Lazy<IStatusBarNotificationService> statusBar,
5359
Lazy<IUsageTracker> usageTracker) : base(CommandSet, CommandId)
5460
{
5561
this.serviceProvider = serviceProvider;
5662
this.editorAdapter = editorAdapter;
5763
this.sessionManager = sessionManager;
5864
this.pullRequestEditorService = pullRequestEditorService;
65+
this.gitHubContextService = gitHubContextService;
66+
this.teamExplorerContext = teamExplorerContext;
5967
this.statusBar = statusBar;
6068
this.usageTracker = usageTracker;
6169

@@ -112,6 +120,11 @@ public override async Task Execute()
112120
return;
113121
}
114122

123+
if (TryNavigateFromHistoryFile(sourceView))
124+
{
125+
return;
126+
}
127+
115128
var relativePath = sessionManager.Value.GetRelativePath(textView.TextBuffer);
116129
if (relativePath == null)
117130
{
@@ -189,6 +202,11 @@ void OnBeforeQueryStatus(object sender, EventArgs e)
189202
return;
190203
}
191204
}
205+
206+
if (TryNavigateFromHistoryFileQueryStatus(sourceView))
207+
{
208+
return;
209+
}
192210
}
193211
catch (Exception ex)
194212
{
@@ -198,6 +216,81 @@ void OnBeforeQueryStatus(object sender, EventArgs e)
198216
Visible = false;
199217
}
200218

219+
bool TryNavigateFromHistoryFileQueryStatus(IVsTextView sourceView)
220+
{
221+
if (teamExplorerContext.Value.ActiveRepository?.LocalPath == null)
222+
{
223+
// Only available when there's an active repository
224+
return false;
225+
}
226+
227+
var filePath = FindPath(sourceView);
228+
if (filePath == null)
229+
{
230+
return false;
231+
}
232+
233+
var objectish = gitHubContextService.Value.FindObjectishForTFSTempFile(filePath);
234+
if (objectish == null)
235+
{
236+
// Not a temporary Team Explorer blob file
237+
return false;
238+
}
239+
240+
// Navigate from history file is active
241+
Text = "Open File in Solution";
242+
Visible = true;
243+
return true;
244+
}
245+
246+
bool TryNavigateFromHistoryFile(IVsTextView sourceView)
247+
{
248+
var repositoryDir = teamExplorerContext.Value.ActiveRepository?.LocalPath;
249+
if (repositoryDir == null)
250+
{
251+
return false;
252+
}
253+
254+
var path = FindPath(sourceView);
255+
if (path == null)
256+
{
257+
return false;
258+
}
259+
260+
var objectish = gitHubContextService.Value.FindObjectishForTFSTempFile(path);
261+
if (objectish == null)
262+
{
263+
return false;
264+
}
265+
266+
var (commitSha, blobPath) = gitHubContextService.Value.ResolveBlobFromHistory(repositoryDir, objectish);
267+
if (blobPath == null)
268+
{
269+
return false;
270+
}
271+
272+
var workingFile = Path.Combine(repositoryDir, blobPath);
273+
VsShellUtilities.OpenDocument(serviceProvider, workingFile, VSConstants.LOGVIEWID.TextView_guid,
274+
out IVsUIHierarchy hierarchy, out uint itemID, out IVsWindowFrame windowFrame, out IVsTextView targetView);
275+
276+
pullRequestEditorService.Value.NavigateToEquivalentPosition(sourceView, targetView);
277+
return true;
278+
}
279+
280+
// See http://microsoft.public.vstudio.extensibility.narkive.com/agfoD1GO/full-pathname-of-file-shown-in-current-view-of-core-editor#post2
281+
static string FindPath(IVsTextView textView)
282+
{
283+
ErrorHandler.ThrowOnFailure(textView.GetBuffer(out IVsTextLines buffer));
284+
var userData = buffer as IVsUserData;
285+
if (userData == null)
286+
{
287+
return null;
288+
}
289+
290+
ErrorHandler.ThrowOnFailure(userData.GetData(typeof(IVsUserData).GUID, out object data));
291+
return data as string;
292+
}
293+
201294
ITextView FindActiveTextView(IDifferenceViewer diffViewer)
202295
{
203296
switch (diffViewer.ActiveViewType)

test/GitHub.App.UnitTests/Services/GitHubContextServiceTests.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using GitHub.Exports;
34
using GitHub.Services;
45
using NSubstitute;
@@ -382,6 +383,117 @@ public void ResolveBlob(string url, string commitish, string objectish, string e
382383
}
383384
}
384385

386+
public class TheResolveBlobFromCommitsMethod
387+
{
388+
[Test]
389+
public void FlatTree()
390+
{
391+
var objectish = "12345678";
392+
var expectCommitSha = "2434215c5489db2bfa2e5249144a3bc532465f97";
393+
var expectBlobPath = "Class1.cs";
394+
var repositoryDir = "repositoryDir";
395+
var blob = Substitute.For<Blob>();
396+
var treeEntry = CreateTreeEntry(TreeEntryTargetType.Blob, blob, expectBlobPath);
397+
var commit = CreateCommit(expectCommitSha, treeEntry);
398+
var repository = CreateRepository(commit);
399+
repository.Lookup<Blob>(objectish).Returns(blob);
400+
var target = CreateGitHubContextService(repositoryDir, repository);
401+
402+
var (commitSha, blobPath) = target.ResolveBlobFromHistory(repositoryDir, objectish);
403+
404+
Assert.That((commitSha, blobPath), Is.EqualTo((expectCommitSha, expectBlobPath)));
405+
}
406+
407+
[Test]
408+
public void NestedTree()
409+
{
410+
var objectish = "12345678";
411+
var expectCommitSha = "2434215c5489db2bfa2e5249144a3bc532465f97";
412+
var expectBlobPath = @"AnnotateFileTests\Class1.cs";
413+
var repositoryDir = "repositoryDir";
414+
var blob = Substitute.For<Blob>();
415+
var blobTreeEntry = CreateTreeEntry(TreeEntryTargetType.Blob, blob, expectBlobPath);
416+
var childTree = CreateTree(blobTreeEntry);
417+
var treeTreeEntry = CreateTreeEntry(TreeEntryTargetType.Tree, childTree, "AnnotateFileTests");
418+
var commit = CreateCommit(expectCommitSha, treeTreeEntry);
419+
var repository = CreateRepository(commit);
420+
repository.Lookup<Blob>(objectish).Returns(blob);
421+
var target = CreateGitHubContextService(repositoryDir, repository);
422+
423+
var (commitSha, blobPath) = target.ResolveBlobFromHistory(repositoryDir, objectish);
424+
425+
Assert.That((commitSha, blobPath), Is.EqualTo((expectCommitSha, expectBlobPath)));
426+
}
427+
428+
[Test]
429+
public void MissingBlob()
430+
{
431+
var objectish = "12345678";
432+
var repositoryDir = "repositoryDir";
433+
var treeEntry = Substitute.For<TreeEntry>();
434+
var repository = CreateRepository();
435+
var target = CreateGitHubContextService(repositoryDir, repository);
436+
437+
var (commitSha, blobPath) = target.ResolveBlobFromHistory(repositoryDir, objectish);
438+
439+
Assert.That((commitSha, blobPath), Is.EqualTo((null as string, null as string)));
440+
}
441+
442+
static IRepository CreateRepository(params Commit[] commits)
443+
{
444+
var repository = Substitute.For<IRepository>();
445+
var enumerator = commits.ToList().GetEnumerator();
446+
repository.Commits.GetEnumerator().Returns(enumerator);
447+
return repository;
448+
}
449+
450+
static Commit CreateCommit(string sha, params TreeEntry[] treeEntries)
451+
{
452+
var commit = Substitute.For<Commit>();
453+
commit.Sha.Returns(sha);
454+
var tree = CreateTree(treeEntries);
455+
commit.Tree.Returns(tree);
456+
return commit;
457+
}
458+
459+
static TreeEntry CreateTreeEntry(TreeEntryTargetType targetType, GitObject target, string path)
460+
{
461+
var treeEntry = Substitute.For<TreeEntry>();
462+
treeEntry.TargetType.Returns(targetType);
463+
treeEntry.Target.Returns(target);
464+
treeEntry.Path.Returns(path);
465+
return treeEntry;
466+
}
467+
468+
static Tree CreateTree(params TreeEntry[] treeEntries)
469+
{
470+
var tree = Substitute.For<Tree>();
471+
var enumerator = treeEntries.ToList().GetEnumerator();
472+
tree.GetEnumerator().Returns(enumerator);
473+
return tree;
474+
}
475+
}
476+
477+
public class TheFindBlobShaForTextViewMethod
478+
{
479+
[TestCase(@"C:\Users\me\AppData\Local\Temp\TFSTemp\vctmp21996_181282.IOpenFromClipboardCommand.783ac965.cs", "783ac965")]
480+
[TestCase(@"\TFSTemp\File.12345678.ext", "12345678")]
481+
[TestCase(@"\TFSTemp\File.abcdefab.ext", "abcdefab")]
482+
[TestCase(@"\TFSTemp\.12345678.", "12345678")]
483+
[TestCase(@"\TFSTemp\File.ABCDEFAB.ext", null)]
484+
[TestCase(@"\TFSTemp\File.1234567.ext", null)]
485+
[TestCase(@"\TFSTemp\File.123456789.ext", null)]
486+
[TestCase(@"\TFSTemp\File.12345678.ext\\", null)]
487+
public void FindObjectishForTFSTempFile(string path, string expectObjectish)
488+
{
489+
var target = CreateGitHubContextService();
490+
491+
var objectish = target.FindObjectishForTFSTempFile(path);
492+
493+
Assert.That(objectish, Is.EqualTo(expectObjectish));
494+
}
495+
}
496+
385497
static GitHubContextService CreateGitHubContextService(string repositoryDir = null, IRepository repository = null)
386498
{
387499
var sp = Substitute.For<IGitHubServiceProvider>();

0 commit comments

Comments
 (0)