diff --git a/src/Microsoft.SourceIndexer.Tasks/GenerateVmrRepoTags.cs b/src/Microsoft.SourceIndexer.Tasks/GenerateVmrRepoTags.cs new file mode 100644 index 0000000..067291a --- /dev/null +++ b/src/Microsoft.SourceIndexer.Tasks/GenerateVmrRepoTags.cs @@ -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(); + + 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(); + 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('/'); + } + } +} diff --git a/src/Microsoft.SourceIndexer.Tasks/Microsoft.SourceIndexer.Tasks.csproj b/src/Microsoft.SourceIndexer.Tasks/Microsoft.SourceIndexer.Tasks.csproj index 3a69713..cb4c01e 100644 --- a/src/Microsoft.SourceIndexer.Tasks/Microsoft.SourceIndexer.Tasks.csproj +++ b/src/Microsoft.SourceIndexer.Tasks/Microsoft.SourceIndexer.Tasks.csproj @@ -8,6 +8,7 @@ + diff --git a/src/SourceBrowser/TestCode/TestSolution/ThisIsAnIntentionallyEnormousFileNameDesignedToOverflowTheNavigationPaneHorizontallySoTheResizableSplitterAndStickyRepoFilterCanBeExercised.cs b/src/SourceBrowser/TestCode/TestSolution/ThisIsAnIntentionallyEnormousFileNameDesignedToOverflowTheNavigationPaneHorizontallySoTheResizableSplitterAndStickyRepoFilterCanBeExercised.cs new file mode 100644 index 0000000..fd6a6ed --- /dev/null +++ b/src/SourceBrowser/TestCode/TestSolution/ThisIsAnIntentionallyEnormousFileNameDesignedToOverflowTheNavigationPaneHorizontallySoTheResizableSplitterAndStickyRepoFilterCanBeExercised.cs @@ -0,0 +1,16 @@ +namespace TestSolution +{ + /// + /// 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. + /// + public static class ThisIsAnIntentionallyEnormousTypeNameDesignedToOverflowTheNavigationPaneHorizontallySoTheResizableSplitterAndStickyRepoFilterCanBeExercised + { + public static string ThisIsAnIntentionallyEnormousMethodNameThatAlsoContributesToTheHorizontalWidthOfASingleResultLineForTestingPurposes() + { + return "overflow"; + } + } +} diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs index 26579de..c3c94df 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Net; +using System.Text; using Microsoft.SourceBrowser.Common; using Folder = Microsoft.SourceBrowser.HtmlGenerator.Folder; @@ -7,7 +9,7 @@ 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) { @@ -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); } } @@ -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(""); + return; + } + + writer.WriteLine(projectExplorerText); + } + + // GetProjectExplorerText returns a project title div immediately followed by its file-tree + // folder div:
Name
INNER
. + // 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 = ""; + var titleEnd = html.IndexOf(closeTag, StringComparison.Ordinal); + if (titleEnd < 0) + { + return false; + } + + titleEnd += closeTag.Length; + var folderOpen = 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; } } diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs index 2c67a3c..144f698 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs @@ -110,7 +110,7 @@ public void FinalizeProjects( IReadOnlyDictionary> configsByAssembly = null) { SortProcessedAssemblies(); - WriteSolutionExplorer(solutionExplorerRoot); + WriteSolutionExplorer(emitAssemblyList, solutionExplorerRoot); CreateReferencesFiles(additionalReferencedSymbolIdsByAssembly, mergedDivergentReferencesByAssembly, configsByAssembly); CreateMasterDeclarationsIndex(); CreateProjectMap(); diff --git a/src/SourceBrowser/src/HtmlGenerator/Program.cs b/src/SourceBrowser/src/HtmlGenerator/Program.cs index 6e36e4a..145a67a 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Program.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Program.cs @@ -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 { @@ -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; } @@ -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); }); } @@ -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); @@ -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) @@ -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)) @@ -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()) { diff --git a/src/SourceBrowser/src/HtmlGenerator/Utilities/Constants.cs b/src/SourceBrowser/src/HtmlGenerator/Utilities/Constants.cs index 0d7d8e4..9c22a42 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Utilities/Constants.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Utilities/Constants.cs @@ -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"; diff --git a/src/SourceBrowser/src/HtmlGenerator/Utilities/Markup.cs b/src/SourceBrowser/src/HtmlGenerator/Utilities/Markup.cs index 6d5105e..efdc11e 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Utilities/Markup.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Utilities/Markup.cs @@ -291,9 +291,15 @@ Solution Explorer
"); } - public static void WriteSolutionExplorerSuffix(TextWriter writer) + public static void WriteSolutionExplorerSuffix(TextWriter writer, bool emitAssemblyList) { - writer.WriteLine("
"); + // 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 + ? @"
Try also browsing the assembly list.
" + : null; + + writer.WriteLine("" + assemblyListLink + ""); } public static void WriteNamespaceExplorerPrefix(string assemblyName, StreamWriter sw, string pathPrefix = "") @@ -353,11 +359,11 @@ public static string GetResultsHtmlPrefix() - +
-Enter a type or member name or filter the assembly list. +Enter a type or member name.
"; diff --git a/src/SourceBrowser/src/SourceIndexServer/wwwroot/index.html b/src/SourceBrowser/src/SourceIndexServer/wwwroot/index.html index 6760d27..f4f7526 100644 --- a/src/SourceBrowser/src/SourceIndexServer/wwwroot/index.html +++ b/src/SourceBrowser/src/SourceIndexServer/wwwroot/index.html @@ -21,6 +21,11 @@ class="navFrame" src="results.html" title="search-results"> +