Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/Microsoft.SourceIndexer.Tasks/GenerateVmrRepoTags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.SourceIndexer.Tasks
{
// Reads the dotnet/dotnet VMR's src/source-manifest.json and emits one repo tag per mapped
// subfolder, so search can be scoped to the originating repo (dotnet/runtime, dotnet/winforms, ...)
// instead of the whole VMR. Source links are unaffected -- this only feeds HtmlGenerator's
// /repoPath tagging, not /serverPath, so files still link back to dotnet/dotnet.
public class GenerateVmrRepoTags : Task
{
// Full path to the extracted VMR's src/source-manifest.json.
[Required]
public string ManifestPath { get; set; }

// Folder the manifest's repository paths are relative to (the extracted VMR's src directory).
[Required]
public string SrcRoot { get; set; }

// One item per repository: ItemSpec is the subfolder's full path, RepoDisplayName its owner/repo tag.
[Output]
public ITaskItem[] RepoTags { get; set; }

public override bool Execute()
{
try
{
ExecuteCore();
return !Log.HasLoggedErrors;
}
catch (Exception ex)
{
Log.LogErrorFromException(ex, true);
return false;
}
}

private void ExecuteCore()
{
RepoTags = Array.Empty<ITaskItem>();

if (!File.Exists(ManifestPath))
{
// Non-VMR builds (or a manifest that moved) simply produce no sub-tags.
Log.LogMessage(MessageImportance.Normal, $"Source manifest '{ManifestPath}' not found; no sub-repo tags generated.");
return;
}

using var doc = JsonDocument.Parse(File.ReadAllText(ManifestPath));
if (!doc.RootElement.TryGetProperty("repositories", out var repositories) ||
repositories.ValueKind != JsonValueKind.Array)
{
Log.LogWarning($"Source manifest '{ManifestPath}' has no 'repositories' array; no sub-repo tags generated.");
return;
}

var tags = new List<ITaskItem>();
foreach (var repo in repositories.EnumerateArray())
{
var path = repo.TryGetProperty("path", out var p) ? p.GetString() : null;
var remoteUri = repo.TryGetProperty("remoteUri", out var r) ? r.GetString() : null;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(remoteUri))
{
continue;
}

var item = new TaskItem(Path.GetFullPath(Path.Combine(SrcRoot, path)));
item.SetMetadata("RepoDisplayName", GetDisplayName(remoteUri));
tags.Add(item);
}

RepoTags = tags.ToArray();
Log.LogMessage(MessageImportance.Normal, $"Generated {tags.Count} sub-repo tag(s) from '{ManifestPath}'.");
}

// Turns a clone URL into an owner/repo tag, matching the RepoDisplayName convention used for
// top-level repositories (the URL minus the github.com prefix).
private static string GetDisplayName(string remoteUri)
{
var s = remoteUri.Trim();
if (s.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
{
s = s.Substring(0, s.Length - ".git".Length);
}

const string prefix = "https://github.com/";
if (s.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return s.Substring(prefix.Length).Trim('/');
}

// Fall back to the URL path for any non-github remote.
return new Uri(s).AbsolutePath.Trim('/');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageReference Include="Microsoft.Build.Framework" />
<PackageReference Include="Microsoft.Build.Tasks.Core" />
<PackageReference Include="Microsoft.Build.Utilities.Core" />
<PackageReference Include="System.Text.Json" />
<PackageReference Include="SharpZipLib" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace TestSolution
{
/// <summary>
/// Deliberately long file name and type name used to exercise the nav-pane layout: a
/// long result line must scroll within its own group (and a long tree node within the
/// Solution Explorer) without pushing the repo filter dropdown off-screen, and the
/// side-by-side panes stay resizable via the splitter. Search for "ThisIsAn" to see it.
/// </summary>
public static class ThisIsAnIntentionallyEnormousTypeNameDesignedToOverflowTheNavigationPaneHorizontallySoTheResizableSplitterAndStickyRepoFilterCanBeExercised
{
public static string ThisIsAnIntentionallyEnormousMethodNameThatAlsoContributesToTheHorizontalWidthOfASingleResultLineForTestingPurposes()
{
return "overflow";
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using Microsoft.SourceBrowser.Common;
using Folder = Microsoft.SourceBrowser.HtmlGenerator.Folder<Microsoft.SourceBrowser.HtmlGenerator.ProjectSkeleton>;

namespace Microsoft.SourceBrowser.HtmlGenerator
{
public partial class SolutionFinalizer
{
private void WriteSolutionExplorer(Folder root = null)
private void WriteSolutionExplorer(bool emitAssemblyList, Folder root = null)
{
if (root == null)
{
Expand All @@ -21,7 +23,7 @@ private void WriteSolutionExplorer(Folder root = null)
Log.Write("Solution Explorer...");
Markup.WriteSolutionExplorerPrefix(writer);
WriteFolder(root, writer);
Markup.WriteSolutionExplorerSuffix(writer);
Markup.WriteSolutionExplorerSuffix(writer, emitAssemblyList);
}
}

Expand Down Expand Up @@ -88,9 +90,84 @@ private void WriteFolder(Folder folder, StreamWriter writer)
private void WriteProject(string assemblyName, string repoName, StreamWriter writer)
{
var projectExplorerText = GetProjectExplorerText(assemblyName, repoName);
if (!string.IsNullOrEmpty(projectExplorerText))
if (string.IsNullOrEmpty(projectExplorerText))
{
writer.WriteLine(projectExplorerText);
return;
}

// Split the project's title row from its file subtree and write the subtree to a
// per-project fragment the client fetches on first expand (see expandCollapseFolder in
// scripts.js). The Solution Explorer document then carries only the repo/solution/project
// skeleton, so a large index no longer forces the browser to parse -- and build collapsed
// DOM for -- every project's whole file tree up front. Projects with no subtree, or any
// that don't split cleanly, fall back to being written inline exactly as before.
if (TrySplitProjectSubtree(projectExplorerText, out var titleHtml, out var folderOpenTag, out var subtreeInner)
&& TryWriteProjectSubtreeFragment(assemblyName, subtreeInner, out var fragmentSrc))
{
writer.Write(titleHtml);
writer.Write(folderOpenTag.Insert(folderOpenTag.Length - 1, " data-src=\"" + WebUtility.HtmlEncode(fragmentSrc) + "\""));
writer.WriteLine("</div>");
return;
}

writer.WriteLine(projectExplorerText);
}

// GetProjectExplorerText returns a project title div immediately followed by its file-tree
// folder div: <div class="projectCSInSolution" ...>Name</div><div class="folder" ...>INNER</div>.
// Peel those apart so the title (and its data-repo hook) can stay inline while INNER is deferred.
private static bool TrySplitProjectSubtree(string html, out string titleHtml, out string folderOpenTag, out string subtreeInner)
{
titleHtml = folderOpenTag = subtreeInner = null;

const string closeTag = "</div>";
var titleEnd = html.IndexOf(closeTag, StringComparison.Ordinal);
if (titleEnd < 0)
{
return false;
}

titleEnd += closeTag.Length;
var folderOpen = html.IndexOf("<div", titleEnd, StringComparison.Ordinal);
if (folderOpen < 0)
{
return false;
}

var folderOpenEnd = html.IndexOf('>', folderOpen);
var lastClose = html.LastIndexOf(closeTag, StringComparison.Ordinal);
if (folderOpenEnd < 0 || lastClose <= folderOpenEnd)
{
return false;
}

var inner = html.Substring(folderOpenEnd + 1, lastClose - (folderOpenEnd + 1));
if (inner.Trim().Length == 0)
{
return false;
}

titleHtml = html.Substring(0, titleEnd);
folderOpenTag = html.Substring(folderOpen, folderOpenEnd - folderOpen + 1);
subtreeInner = inner;
return true;
}

private bool TryWriteProjectSubtreeFragment(string assemblyName, string subtreeInner, out string fragmentSrc)
{
fragmentSrc = assemblyName + "/" + Constants.SolutionExplorerFragment;
try
{
File.WriteAllText(
Path.Combine(SolutionDestinationFolder, assemblyName, Constants.SolutionExplorerFragment),
subtreeInner,
Encoding.UTF8);
return true;
}
catch (Exception ex)
{
Log.Exception(ex, "Failed writing Solution Explorer fragment for " + assemblyName);
return false;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public void FinalizeProjects(
IReadOnlyDictionary<string, IReadOnlyList<string>> configsByAssembly = null)
{
SortProcessedAssemblies();
WriteSolutionExplorer(solutionExplorerRoot);
WriteSolutionExplorer(emitAssemblyList, solutionExplorerRoot);
CreateReferencesFiles(additionalReferencedSymbolIdsByAssembly, mergedDivergentReferencesByAssembly, configsByAssembly);
CreateMasterDeclarationsIndex();
CreateProjectMap();
Expand Down
26 changes: 10 additions & 16 deletions src/SourceBrowser/src/HtmlGenerator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ await IndexSolutionsAsync(options.Projects, options.Properties, federation, opti
// existed. Finalization happens directly, exactly as today -- this is what makes the
// no-config case trivially byte-identical (the code path is literally untouched).
FinalizeProjects(options.EmitAssemblyList, federation);
WebsiteFinalizer.Finalize(runOutputRoot, options.EmitAssemblyList, federation, options.ShowBranding);
WebsiteFinalizer.Finalize(runOutputRoot, federation, options.ShowBranding);
}
else
{
Expand Down Expand Up @@ -239,7 +239,7 @@ private static void RunConfigMergeIfNeeded(string outRoot, bool emitAssemblyList
// as the default/no-config path would, straight into the shared "index/".
Paths.SolutionDestinationFolder = Path.Combine(outRoot, "obj", configs[0]);
FinalizeProjects(emitAssemblyList, federation);
WebsiteFinalizer.Finalize(outRoot, emitAssemblyList, federation, showBranding);
WebsiteFinalizer.Finalize(outRoot, federation, showBranding);
return;
}

Expand All @@ -256,7 +256,7 @@ private static void RunConfigMergeIfNeeded(string outRoot, bool emitAssemblyList
StringComparer.Ordinal);

ConfigAwareProjectFinalizer.Finalize(configObjRoots, Paths.WebsiteDestinationFolder, emitAssemblyList, federation, axisTagsByConfig);
WebsiteFinalizer.Finalize(outRoot, emitAssemblyList, federation, showBranding);
WebsiteFinalizer.Finalize(outRoot, federation, showBranding);
});
}

Expand Down Expand Up @@ -595,7 +595,7 @@ private static void GenerateLooseFilesProject(string projectName, string solutio

internal static class WebsiteFinalizer
{
public static void Finalize(string destinationFolder, bool emitAssemblyList, Federation federation, bool showBranding)
public static void Finalize(string destinationFolder, Federation federation, bool showBranding)
{
string sourcePath = Assembly.GetEntryAssembly().Location;
sourcePath = Path.GetDirectoryName(sourcePath);
Expand All @@ -611,7 +611,7 @@ public static void Finalize(string destinationFolder, bool emitAssemblyList, Fed

StampOverviewHtmlWithDate(destinationFolder);

ApplyScriptsJsCustomizations(destinationFolder, emitAssemblyList, federation, showBranding);
ApplyScriptsJsCustomizations(destinationFolder, federation, showBranding);
}

private static void StampOverviewHtmlWithDate(string destinationFolder)
Expand Down Expand Up @@ -702,12 +702,12 @@ private static string GetSourceBrowserVersion()
// The generated site can run either through SourceIndexServer (which serves wwwroot/scripts.js
// as its baseline, byte-identical to the checked-in template) or as pure static files, where
// the copy under index/ -- and, at runtime, SourceIndexServer's own RootPath handler, which is
// registered ahead of its wwwroot handler -- is what's actually served. All three toggles below
// registered ahead of its wwwroot handler -- is what's actually served. Both toggles below
// used to independently re-read wwwroot/scripts.js and overwrite index/scripts.js, which meant
// combining more than one (e.g. /assemblylist with a federation, or either alongside
// /showBranding) silently discarded whichever ran first. They're composed into one read-modify
// sequence here so any combination of flags ends up in the final file.
private static void ApplyScriptsJsCustomizations(string destinationFolder, bool emitAssemblyList, Federation federation, bool showBranding)
// combining them (a federation alongside /showBranding) silently discarded whichever ran first.
// They're composed into one read-modify sequence here so any combination of flags ends up in
// the final file.
private static void ApplyScriptsJsCustomizations(string destinationFolder, Federation federation, bool showBranding)
{
var source = Path.Combine(destinationFolder, "wwwroot/scripts.js");
if (!File.Exists(source))
Expand All @@ -718,12 +718,6 @@ private static void ApplyScriptsJsCustomizations(string destinationFolder, bool
var text = File.ReadAllText(source);
var changed = false;

if (emitAssemblyList)
{
text = text.Replace("/*USE_SOLUTION_EXPLORER*/true/*USE_SOLUTION_EXPLORER*/", "false");
changed = true;
}

var sb = new StringBuilder();
foreach (var server in federation.GetServers())
{
Expand Down
3 changes: 3 additions & 0 deletions src/SourceBrowser/src/HtmlGenerator/Utilities/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public class Constants
public static readonly string ClassificationPunctuation = "punctuation";
public static readonly string ProjectExplorer = "ProjectExplorer";
public static readonly string SolutionExplorer = "SolutionExplorer";
// Per-project file subtree the merged Solution Explorer defers and the client fetches on
// first expand (see SolutionFinalizer.WriteProject), written alongside ProjectExplorer.html.
public static readonly string SolutionExplorerFragment = "SolutionExplorerFragment.html";
public static readonly string HuffmanFileName = "Huffman.txt";
public static readonly string TopReferencedAssemblies = "TopReferencedAssemblies";
public static readonly string BaseMembersFileName = "BaseMembers";
Expand Down
14 changes: 10 additions & 4 deletions src/SourceBrowser/src/HtmlGenerator/Utilities/Markup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -291,9 +291,15 @@ Solution Explorer
<div id=""rootFolder"" style=""display: none;"" class=""folderTitle"">");
}

public static void WriteSolutionExplorerSuffix(TextWriter writer)
public static void WriteSolutionExplorerSuffix(TextWriter writer, bool emitAssemblyList)
{
writer.WriteLine("</div><script>onSolutionExplorerLoad();</script></body></html>");
// Reciprocal of the "solution explorer" link on the assembly list (results.html); only
// emitted when that list was generated, so it never points at an empty results page.
var assemblyListLink = emitAssemblyList
? @"<div class=""note"">Try also browsing the <a href=""results.html"" class=""blueLink"">assembly list</a>.</div>"
: null;

writer.WriteLine("</div>" + assemblyListLink + "<script>onSolutionExplorerLoad();</script></body></html>");
}

public static void WriteNamespaceExplorerPrefix(string assemblyName, StreamWriter sw, string pathPrefix = "")
Expand Down Expand Up @@ -353,11 +359,11 @@ public static string GetResultsHtmlPrefix()
<link rel=""stylesheet"" href=""styles.css"" />
<script src=""scripts.js""></script>
</head>
<body onload=""onResultsLoad();"">
<body class=""resultsBody"" onload=""onResultsLoad();"">
<select id=""repo-filter"" style=""display:none"" aria-label=""Filter search to a repo""></select>
<div id=""symbols"" aria-live=""polite"">
<div class=""note"">
Enter a type or member name or <a href=""/#q=assembly%20"" target=""_top"" class=""blueLink"" onclick=""populateSearchBox('assembly '); return false;"">filter the assembly list</a>.
Enter a type or member name.
</div>
<div class=""resultGroup"">
";
Expand Down
5 changes: 5 additions & 0 deletions src/SourceBrowser/src/SourceIndexServer/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
class="navFrame"
src="results.html"
title="search-results"></iframe>
<div class="paneResizer"
id="paneResizer"
role="separator"
aria-orientation="vertical"
title="Drag to resize"></div>
<iframe name="s"
id="s"
class="contentFrame"
Expand Down
Loading
Loading