From 3ca1d274179965ba645797d78c97aee4adfd1c60 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Sun, 19 Jul 2026 10:55:37 -0700 Subject: [PATCH] Fix Solution Explorer repo grouping, filtering, and theming for merged VMR sites Bundles the "make the site work as expected" fixes for multi-repo/VMR merged sites: - Tag each project with its full repo chain so a parent repo (e.g. dotnet/vmr) includes its nested sub-repos (dotnet/subx, dotnet/suby) in both search scope and the Solution Explorer, shown nested under the parent. - Give /repoPath-mapped repos their own repo-filter entry, matching /repo. - Warn (non-critical) when the same repo is passed twice with different URLs, naming the winning vs losing URL. - Fix multi-solution repos rendering with the light theme. - Make repo filtering ancestor-aware and hide grouping folders (including MSBuild solution folders) a filter leaves empty. - Render repo/solution grouping bands full-width at every nesting depth, with only the text/arrow carrying the depth indent. Extends the sample (HtmlGenerator launchSettings + TestCode/Vmr) to cover the nested-repo and duplicate-URL scenarios. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/SourceBrowser/TestCode/RepoB/RepoB2.slnx | 3 + .../TestCode/RepoB/RepoBLib2/RepoBLib2.csproj | 8 + .../TestCode/RepoB/RepoBLib2/Widget.cs | 21 +++ src/SourceBrowser/TestCode/Vmr/Vmr.slnx | 5 + .../TestCode/Vmr/VmrCore/Core.cs | 16 ++ .../TestCode/Vmr/VmrCore/VmrCore.csproj | 8 + .../TestCode/Vmr/src/SubRepoX/Alpha.cs | 16 ++ .../TestCode/Vmr/src/SubRepoX/SubRepoX.csproj | 8 + .../TestCode/Vmr/src/SubRepoY/Beta.cs | 16 ++ .../TestCode/Vmr/src/SubRepoY/SubRepoY.csproj | 8 + .../CommandLineOptionsTests.cs | 15 ++ .../ConfigAwareProjectFinalizerTests.cs | 4 +- .../SerializationUnitTests.cs | 4 +- .../SolutionExplorerGroupingTests.cs | 85 ++++++++++ .../src/HtmlGenerator/CommandLineOptions.cs | 20 ++- .../Pass1-Generation/GenerateFromBuildLog.cs | 11 +- .../ProjectGenerator.ProjectInfo.cs | 8 + .../Pass1-Generation/ProjectGenerator.cs | 10 +- .../SolutionGenerator.SolutionExplorer.cs | 16 +- .../Pass1-Generation/SolutionGenerator.cs | 28 +++- .../ConfigAwareProjectFinalizer.cs | 47 +++--- .../Pass2-Finalization/ProjectFinalizer.cs | 5 + .../SolutionFinalizer.SolutionExplorer.cs | 29 ++-- .../Pass2-Finalization/SolutionFinalizer.cs | 3 +- .../src/HtmlGenerator/Program.cs | 145 +++++++++++++++--- .../src/HtmlGenerator/ProjectSkeleton.cs | 8 +- .../Properties/launchSettings.json | 2 +- .../src/HtmlGenerator/Utilities/Folder`1.cs | 5 + .../HtmlGenerator/Utilities/Serialization.cs | 16 +- .../SourceIndexServer.Tests/IndexUnitTests.cs | 34 ++++ .../SourceIndexServer/Models/AssemblyInfo.cs | 8 + .../src/SourceIndexServer/Models/Index.cs | 2 +- .../src/SourceIndexServer/Models/Query.cs | 18 ++- .../src/SourceIndexServer/wwwroot/scripts.js | 126 +++++++++++++-- .../src/SourceIndexServer/wwwroot/styles.css | 19 ++- 35 files changed, 687 insertions(+), 90 deletions(-) create mode 100644 src/SourceBrowser/TestCode/RepoB/RepoB2.slnx create mode 100644 src/SourceBrowser/TestCode/RepoB/RepoBLib2/RepoBLib2.csproj create mode 100644 src/SourceBrowser/TestCode/RepoB/RepoBLib2/Widget.cs create mode 100644 src/SourceBrowser/TestCode/Vmr/Vmr.slnx create mode 100644 src/SourceBrowser/TestCode/Vmr/VmrCore/Core.cs create mode 100644 src/SourceBrowser/TestCode/Vmr/VmrCore/VmrCore.csproj create mode 100644 src/SourceBrowser/TestCode/Vmr/src/SubRepoX/Alpha.cs create mode 100644 src/SourceBrowser/TestCode/Vmr/src/SubRepoX/SubRepoX.csproj create mode 100644 src/SourceBrowser/TestCode/Vmr/src/SubRepoY/Beta.cs create mode 100644 src/SourceBrowser/TestCode/Vmr/src/SubRepoY/SubRepoY.csproj diff --git a/src/SourceBrowser/TestCode/RepoB/RepoB2.slnx b/src/SourceBrowser/TestCode/RepoB/RepoB2.slnx new file mode 100644 index 0000000..cde18d8 --- /dev/null +++ b/src/SourceBrowser/TestCode/RepoB/RepoB2.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/SourceBrowser/TestCode/RepoB/RepoBLib2/RepoBLib2.csproj b/src/SourceBrowser/TestCode/RepoB/RepoBLib2/RepoBLib2.csproj new file mode 100644 index 0000000..dd0c0d5 --- /dev/null +++ b/src/SourceBrowser/TestCode/RepoB/RepoBLib2/RepoBLib2.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + disable + + + diff --git a/src/SourceBrowser/TestCode/RepoB/RepoBLib2/Widget.cs b/src/SourceBrowser/TestCode/RepoB/RepoBLib2/Widget.cs new file mode 100644 index 0000000..a8e02ae --- /dev/null +++ b/src/SourceBrowser/TestCode/RepoB/RepoBLib2/Widget.cs @@ -0,0 +1,21 @@ +namespace RepoBLib2 +{ + /// + /// Lives in a second solution (RepoB2.slnx) under the same RepoB /repoPath, so the merged + /// site groups both RepoB solutions under a Solution Explorer solutionTitle node -- the only + /// place the multi-solution grouping (and its theming) is exercised. + /// + public class Widget + { + public string Describe() => WidgetInfo.Label; + } + + /// + /// Referenced by so RepoBLib2 has an indexed cross-symbol reference; without + /// one, Pass2's DiscoverProjects skips the assembly (no R folder) and it never reaches the site. + /// + public static class WidgetInfo + { + public static string Label => "RepoBLib2.Widget"; + } +} diff --git a/src/SourceBrowser/TestCode/Vmr/Vmr.slnx b/src/SourceBrowser/TestCode/Vmr/Vmr.slnx new file mode 100644 index 0000000..0ebc6c9 --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/Vmr.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/src/SourceBrowser/TestCode/Vmr/VmrCore/Core.cs b/src/SourceBrowser/TestCode/Vmr/VmrCore/Core.cs new file mode 100644 index 0000000..9d50e9f --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/VmrCore/Core.cs @@ -0,0 +1,16 @@ +namespace VmrCore +{ + /// + /// Sits directly under the Vmr root (not under any src/SubRepo* /repoPath), so per-project repo + /// resolution falls back to the whole input's tag (dotnet/vmr) -- the fallback arm of the fix. + /// + public class Core + { + public string Describe() => CoreInfo.Label; + } + + public static class CoreInfo + { + public static string Label => "VmrCore.Core"; + } +} diff --git a/src/SourceBrowser/TestCode/Vmr/VmrCore/VmrCore.csproj b/src/SourceBrowser/TestCode/Vmr/VmrCore/VmrCore.csproj new file mode 100644 index 0000000..dd0c0d5 --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/VmrCore/VmrCore.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + disable + + + diff --git a/src/SourceBrowser/TestCode/Vmr/src/SubRepoX/Alpha.cs b/src/SourceBrowser/TestCode/Vmr/src/SubRepoX/Alpha.cs new file mode 100644 index 0000000..7b4fd7f --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/src/SubRepoX/Alpha.cs @@ -0,0 +1,16 @@ +namespace SubRepoX +{ + /// + /// Lives under Vmr/src/SubRepoX, tagged via a nested /repoPath to dotnet/subx even though it's part + /// of the single Vmr.slnx input -- the sub-repo case that used to inherit the parent's tag. + /// + public class Alpha + { + public string Describe() => AlphaInfo.Label; + } + + public static class AlphaInfo + { + public static string Label => "SubRepoX.Alpha"; + } +} diff --git a/src/SourceBrowser/TestCode/Vmr/src/SubRepoX/SubRepoX.csproj b/src/SourceBrowser/TestCode/Vmr/src/SubRepoX/SubRepoX.csproj new file mode 100644 index 0000000..dd0c0d5 --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/src/SubRepoX/SubRepoX.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + disable + + + diff --git a/src/SourceBrowser/TestCode/Vmr/src/SubRepoY/Beta.cs b/src/SourceBrowser/TestCode/Vmr/src/SubRepoY/Beta.cs new file mode 100644 index 0000000..613afc5 --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/src/SubRepoY/Beta.cs @@ -0,0 +1,16 @@ +namespace SubRepoY +{ + /// + /// Lives under Vmr/src/SubRepoY, tagged via a nested /repoPath to dotnet/suby -- a second sub-repo + /// under the same input, so the merged site distinguishes subx/suby/vmr rather than one repo. + /// + public class Beta + { + public string Describe() => BetaInfo.Label; + } + + public static class BetaInfo + { + public static string Label => "SubRepoY.Beta"; + } +} diff --git a/src/SourceBrowser/TestCode/Vmr/src/SubRepoY/SubRepoY.csproj b/src/SourceBrowser/TestCode/Vmr/src/SubRepoY/SubRepoY.csproj new file mode 100644 index 0000000..dd0c0d5 --- /dev/null +++ b/src/SourceBrowser/TestCode/Vmr/src/SubRepoY/SubRepoY.csproj @@ -0,0 +1,8 @@ + + + + net10.0 + disable + + + diff --git a/src/SourceBrowser/src/HtmlGenerator.Tests/CommandLineOptionsTests.cs b/src/SourceBrowser/src/HtmlGenerator.Tests/CommandLineOptionsTests.cs index 8ff3e5b..a009338 100644 --- a/src/SourceBrowser/src/HtmlGenerator.Tests/CommandLineOptionsTests.cs +++ b/src/SourceBrowser/src/HtmlGenerator.Tests/CommandLineOptionsTests.cs @@ -112,6 +112,21 @@ public void Repo_meta_option_sets_both_repo_path_and_server_path_mappings() serverMapping.Value.ShouldBe("https://example.com/"); } + [TestMethod] + public void Duplicate_repo_folder_with_different_urls_keeps_the_last_one() + { + // The VMR is listed as both dotnet/dotnet and dotnet-dotnet-windows, which resolve to one + // local checkout -- so the same folder gets two /repo: URLs. Last writer wins (a warning is + // logged naming the kept vs. ignored URL); the folder still maps to exactly one URL. + var options = CommandLineOptions.Parse( + "/repo:\"a\"=\"dotnet/dotnet\"=\"https://example.com/first/\"", + "/repo:\"a\"=\"dotnet/dotnet\"=\"https://example.com/second/\""); + + var serverMapping = options.ServerPathMappings.ShouldHaveSingleItem(); + serverMapping.Key.ShouldBe(Path.GetFullPath("a")); + serverMapping.Value.ShouldBe("https://example.com/second/"); + } + [TestMethod] public void Repo_meta_option_requires_all_three_segments() { diff --git a/src/SourceBrowser/src/HtmlGenerator.Tests/ConfigAwareProjectFinalizerTests.cs b/src/SourceBrowser/src/HtmlGenerator.Tests/ConfigAwareProjectFinalizerTests.cs index 06fbb78..2520ad0 100644 --- a/src/SourceBrowser/src/HtmlGenerator.Tests/ConfigAwareProjectFinalizerTests.cs +++ b/src/SourceBrowser/src/HtmlGenerator.Tests/ConfigAwareProjectFinalizerTests.cs @@ -335,9 +335,9 @@ public void Finalize_GroupsSolutionExplorer_UnderRepoFolders_WhenTheMergedSiteSp var solutionExplorerHtml = File.ReadAllText(Path.Combine(websiteDestinationFolder, Constants.SolutionExplorer + ".html")); - solutionExplorerHtml.Contains("
ClangSharp
").ShouldBeTrue( + solutionExplorerHtml.Contains("
ClangSharp
").ShouldBeTrue( "Utils must be nested under a ClangSharp repo folder now that the merged site spans two repos. Actual: " + solutionExplorerHtml); - solutionExplorerHtml.Contains("
LLVMSharp
").ShouldBeTrue( + solutionExplorerHtml.Contains("
LLVMSharp
").ShouldBeTrue( "App must be nested under an LLVMSharp repo folder now that the merged site spans two repos. Actual: " + solutionExplorerHtml); } diff --git a/src/SourceBrowser/src/HtmlGenerator.Tests/SerializationUnitTests.cs b/src/SourceBrowser/src/HtmlGenerator.Tests/SerializationUnitTests.cs index fe8332a..72cb858 100644 --- a/src/SourceBrowser/src/HtmlGenerator.Tests/SerializationUnitTests.cs +++ b/src/SourceBrowser/src/HtmlGenerator.Tests/SerializationUnitTests.cs @@ -68,8 +68,8 @@ public void WriteProjectMap_adds_repo_and_solution_fields_when_any_assembly_is_t var lineA = lines.Single(l => l.StartsWith("A;", StringComparison.Ordinal)); var lineB = lines.Single(l => l.StartsWith("B;", StringComparison.Ordinal)); - Assert.AreEqual("A;0;1;clangsharp;ClangSharp", lineA); - Assert.AreEqual("B;1;0;;", lineB); + Assert.AreEqual("A;0;1;clangsharp;ClangSharp;clangsharp", lineA); + Assert.AreEqual("B;1;0;;;", lineB); } finally { diff --git a/src/SourceBrowser/src/HtmlGenerator.Tests/SolutionExplorerGroupingTests.cs b/src/SourceBrowser/src/HtmlGenerator.Tests/SolutionExplorerGroupingTests.cs index e4a10ae..31afa22 100644 --- a/src/SourceBrowser/src/HtmlGenerator.Tests/SolutionExplorerGroupingTests.cs +++ b/src/SourceBrowser/src/HtmlGenerator.Tests/SolutionExplorerGroupingTests.cs @@ -78,5 +78,90 @@ public void Repeated_calls_for_the_same_repo_reuse_the_same_folder_node() first.ShouldBeSameAs(second); root.Folders.Count.ShouldBe(1); } + + [TestMethod] + public void ResolveRepoName_prefers_the_most_specific_nested_mapping() + { + // A single VMR-style input rooted at the parent folder, with nested sub-repo mappings: each + // project must tag to the deepest mapping containing its own folder, not the parent's. + var mappings = new Dictionary + { + [@"C:\vmr"] = "dotnet/vmr", + [@"C:\vmr\src\arcade"] = "dotnet/arcade", + [@"C:\vmr\src\roslyn"] = "dotnet/roslyn", + }; + + Program.ResolveRepoName(@"C:\vmr\src\arcade\src\Foo\Foo.csproj", mappings, "dotnet/vmr").ShouldBe("dotnet/arcade"); + Program.ResolveRepoName(@"C:\vmr\src\roslyn\Bar\Bar.csproj", mappings, "dotnet/vmr").ShouldBe("dotnet/roslyn"); + } + + [TestMethod] + public void ResolveRepoName_falls_back_to_the_input_tag_outside_any_nested_mapping() + { + var mappings = new Dictionary + { + [@"C:\vmr"] = "dotnet/vmr", + [@"C:\vmr\src\arcade"] = "dotnet/arcade", + }; + + // Directly under the VMR root but not under any sub-repo -> the whole input's tag. + Program.ResolveRepoName(@"C:\vmr\eng\Build\Build.csproj", mappings, "dotnet/vmr").ShouldBe("dotnet/vmr"); + // No mapping contains it and no fallback -> untagged. + Program.ResolveRepoName(@"C:\other\Baz\Baz.csproj", mappings, "").ShouldBe(""); + } + + [TestMethod] + public void ResolveRepoChain_returns_the_full_ancestry_outermost_first() + { + var mappings = new Dictionary + { + [@"C:\vmr"] = "dotnet/vmr", + [@"C:\vmr\src\arcade"] = "dotnet/arcade", + }; + + // A sub-repo project carries both its parent (vmr) and its own repo (arcade), parent first. + Program.ResolveRepoChain(@"C:\vmr\src\arcade\src\Foo\Foo.csproj", mappings, "dotnet/vmr") + .ShouldBe(new[] { "dotnet/vmr", "dotnet/arcade" }); + + // A project only under the VMR root is a single-element chain of just the parent. + Program.ResolveRepoChain(@"C:\vmr\eng\Build\Build.csproj", mappings, "dotnet/vmr") + .ShouldBe(new[] { "dotnet/vmr" }); + } + + [TestMethod] + public void ResolveRepoChain_falls_back_to_the_input_tag_or_empty() + { + var mappings = new Dictionary + { + [@"C:\vmr\src\arcade"] = "dotnet/arcade", + }; + + // No mapping contains it -> single-element fallback chain. + Program.ResolveRepoChain(@"C:\other\Baz\Baz.csproj", mappings, "dotnet/vmr") + .ShouldBe(new[] { "dotnet/vmr" }); + // No mapping and no fallback -> untagged (empty chain). + Program.ResolveRepoChain(@"C:\other\Baz\Baz.csproj", mappings, "").ShouldBeEmpty(); + } + + [TestMethod] + public void Nested_repo_chain_groups_a_sub_repo_under_its_parent_repo_folder() + { + var root = new Folder(); + var solutionCounts = new Dictionary { ["dotnet/subx"] = 1 }; + + var folder = Program.GetSolutionExplorerGroupingFolder( + root, new[] { "dotnet/vmr", "dotnet/subx" }, "SubX", distinctRepoCount: 3, solutionCounts); + + // The sub-repo folder is nested under its parent repo folder, each carrying its own ancestry. + var vmrFolder = root.Folders["dotnet/vmr"]; + vmrFolder.Kind.ShouldBe(FolderKind.Repo); + vmrFolder.RepoChain.ShouldBe(new[] { "dotnet/vmr" }); + + var subxFolder = vmrFolder.Folders["dotnet/subx"]; + subxFolder.ShouldBeSameAs(folder); + subxFolder.Kind.ShouldBe(FolderKind.Repo); + subxFolder.RepoName.ShouldBe("dotnet/subx"); + subxFolder.RepoChain.ShouldBe(new[] { "dotnet/vmr", "dotnet/subx" }); + } } } diff --git a/src/SourceBrowser/src/HtmlGenerator/CommandLineOptions.cs b/src/SourceBrowser/src/HtmlGenerator/CommandLineOptions.cs index 0bc81e9..692a81a 100644 --- a/src/SourceBrowser/src/HtmlGenerator/CommandLineOptions.cs +++ b/src/SourceBrowser/src/HtmlGenerator/CommandLineOptions.cs @@ -157,6 +157,22 @@ public static CommandLineOptions Parse(params string[] args) var configAxes = new Dictionary(StringComparer.OrdinalIgnoreCase); var mergeConfigsOnly = false; + // Both /serverPath: and /repo: map a local repo folder to a source-link base URL, keyed by + // that folder. Mapping the same folder twice with different URLs (e.g. the VMR listed as both + // dotnet/dotnet and dotnet-dotnet-windows, resolving to one checkout) is last-writer-wins, so + // warn which URL is kept and which is dropped rather than silently losing one. + void MapServerPath(string folder, string url) + { + if (serverPathMappings.TryGetValue(folder, out var existing) && existing != url) + { + Log.Write( + $"Duplicate server URL for repo folder '{folder}': keeping '{url}' (last wins), ignoring '{existing}'.", + ConsoleColor.Yellow); + } + + serverPathMappings[folder] = url; + } + foreach (var arg in args) { if (arg.StartsWith("/out:", StringComparison.Ordinal)) @@ -190,7 +206,7 @@ public static CommandLineOptions Parse(params string[] args) continue; } - serverPathMappings[Path.GetFullPath(match.Groups["from"].Value)] = match.Groups["to"].Value; + MapServerPath(Path.GetFullPath(match.Groups["from"].Value), match.Groups["to"].Value); continue; } @@ -240,7 +256,7 @@ public static CommandLineOptions Parse(params string[] args) var repoFolder = Path.GetFullPath(match.Groups["from"].Value); repoPathMappings[repoFolder] = match.Groups["name"].Value; - serverPathMappings[repoFolder] = match.Groups["to"].Value; + MapServerPath(repoFolder, match.Groups["to"].Value); continue; } diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/GenerateFromBuildLog.cs b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/GenerateFromBuildLog.cs index 4afd0d6..38184a9 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/GenerateFromBuildLog.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/GenerateFromBuildLog.cs @@ -23,7 +23,10 @@ public static async Task GenerateInvocationAsync(CompilerInvocation invocation, Dictionary<(string, string), string> typeForwards = null, bool includeSourceGeneratedDocuments = true, string repoName = "", - string solutionName = "") + string solutionName = "", + IReadOnlyDictionary repoPathMappings = null, + int distinctRepoCount = 0, + IReadOnlyDictionary solutionCountsByRepo = null) { try { @@ -48,6 +51,9 @@ public static async Task GenerateInvocationAsync(CompilerInvocation invocation, solutionGenerator.GlobalAssemblyList = assemblyNames; solutionGenerator.RepoName = repoName ?? string.Empty; solutionGenerator.SolutionName = solutionName ?? string.Empty; + solutionGenerator.RepoPathMappings = repoPathMappings; + solutionGenerator.DistinctRepoCount = distinctRepoCount; + solutionGenerator.SolutionCountsByRepo = solutionCountsByRepo; await solutionGenerator.GenerateAsync(cancellationToken, processedAssemblyList, solutionExplorerRoot); } else @@ -60,6 +66,9 @@ public static async Task GenerateInvocationAsync(CompilerInvocation invocation, typeForwards: typeForwards); solutionGenerator.RepoName = repoName ?? string.Empty; solutionGenerator.SolutionName = solutionName ?? string.Empty; + solutionGenerator.RepoPathMappings = repoPathMappings; + solutionGenerator.DistinctRepoCount = distinctRepoCount; + solutionGenerator.SolutionCountsByRepo = solutionCountsByRepo; await solutionGenerator.GenerateAsync(cancellationToken); } } diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.ProjectInfo.cs b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.ProjectInfo.cs index 4d181d3..86f3138 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.ProjectInfo.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.ProjectInfo.cs @@ -31,6 +31,14 @@ private void GenerateProjectInfo() sb.Append("RepoName=").AppendLine(RepoName); } + // Full repo ancestry (outermost first, own repo last), '|'-joined -- lets a parent repo + // include its nested sub-repos in filtering/grouping. '|' can't occur in an owner/repo name, + // so it stays a safe field separator here and in Assemblies.txt. + if (RepoChain is { Count: > 0 }) + { + sb.Append("RepoChain=").AppendLine(string.Join("|", RepoChain)); + } + if (!string.IsNullOrEmpty(SolutionName)) { sb.Append("SolutionName=").AppendLine(SolutionName); diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.cs b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.cs index b1e9eb6..5f414e4 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/ProjectGenerator.cs @@ -27,8 +27,14 @@ public partial class ProjectGenerator public string ProjectSourcePath { get; set; } public string ProjectFilePath { get; private set; } - /// Repo/solution tags inherited from the owning SolutionGenerator, if any. - public string RepoName => SolutionGenerator?.RepoName ?? string.Empty; + /// Repo tag resolved per project from the owning SolutionGenerator's /repoPath + /// mappings (falling back to its per-input tag); solution tag is inherited as-is. + public string RepoName => SolutionGenerator?.ResolveRepoName(ProjectFilePath) ?? string.Empty; + + /// Repo ancestry resolved per project (outermost repo first, own repo last), so a + /// parent repo can include its nested sub-repos in filtering/grouping. See + /// . + public IReadOnlyList RepoChain => SolutionGenerator?.ResolveRepoChain(ProjectFilePath) ?? (IReadOnlyList)System.Array.Empty(); public string SolutionName => SolutionGenerator?.SolutionName ?? string.Empty; public List OtherFiles { get; set; } public IEnumerable PluginSymbolVisitors { get; private set; } diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/SolutionGenerator.SolutionExplorer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/SolutionGenerator.SolutionExplorer.cs index 144a8cc..ee5d783 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/SolutionGenerator.SolutionExplorer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass1-Generation/SolutionGenerator.SolutionExplorer.cs @@ -51,6 +51,18 @@ private void AddProjectToFolder(Folder folder, Project project, IEnumerable(); + // Repo/Solution grouping is per project: a single input can span several repos (e.g. a VMR + // whose sub-repos are tagged via nested /repoPath), so descend this project's full repo + // ancestry (parent repo -> sub-repo) before laying down the .sln folder chain underneath it. + var repoChain = ResolveRepoChain(project.FilePath); + var repoName = repoChain.Count > 0 ? repoChain[repoChain.Count - 1] : string.Empty; + folder = Program.GetSolutionExplorerGroupingFolder(folder, repoChain, SolutionName, DistinctRepoCount, SolutionCountsByRepo); + + AddProjectToFolderCore(folder, project, folderList, repoName, repoChain); + } + + private void AddProjectToFolderCore(Folder folder, Project project, string[] folderList, string repoName, IReadOnlyList repoChain) + { // Additive persistence only -- see Constants.SolutionFolderFileName. Written regardless of // whether the project ends up nested or at the root (empty file = root), and is best-effort: // a project this Pass1 run didn't actually generate output for (e.g. it was filtered out @@ -64,12 +76,12 @@ private void AddProjectToFolder(Folder folder, Project project, IEnumerable /// Optional repo display name tag applied to every assembly generated from this solution, /// set by the caller (see Program.IndexSolutionsAsync) via /repoPath or /repo. Empty when - /// untagged, which is the default and keeps generated output unchanged. + /// untagged, which is the default and keeps generated output unchanged. Used only as the + /// fallback for projects not under any nested mapping -- see . /// public string RepoName { get; set; } = string.Empty; + /// + /// All /repoPath (and /repo) folder-to-name mappings, so each project's repo tag can be + /// resolved from its own folder rather than inheriting the whole input's tag. Lets one input + /// (e.g. a VMR) tag its sub-repo projects individually. Null/empty leaves every project on the + /// fallback. + /// + public IReadOnlyDictionary RepoPathMappings { get; set; } + + /// Site-wide distinct repo count and per-repo solution counts, computed once by the + /// caller, used to decide whether Solution Explorer Repo/Solution grouping folders apply -- see + /// Program.GetSolutionExplorerGroupingFolder. + public int DistinctRepoCount { get; set; } + public IReadOnlyDictionary SolutionCountsByRepo { get; set; } + + /// Resolves the repo tag for a single project: most specific /repoPath mapping + /// containing the project's folder wins, falling back to this solution's . + public string ResolveRepoName(string projectFilePath) + => Program.ResolveRepoName(projectFilePath, RepoPathMappings, RepoName); + + /// Resolves a single project's full repo ancestry (outermost first, own repo last) + /// from the /repoPath mappings, so a parent repo can group its nested sub-repos. See + /// . + public IReadOnlyList ResolveRepoChain(string projectFilePath) + => Program.ResolveRepoChain(projectFilePath, RepoPathMappings, RepoName); + /// /// Optional solution display name tag applied to every assembly generated from this /// solution, auto-derived from the top-level .sln/.slnx file name. Empty for standalone diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ConfigAwareProjectFinalizer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ConfigAwareProjectFinalizer.cs index 2c71cd2..acdf9d5 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ConfigAwareProjectFinalizer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ConfigAwareProjectFinalizer.cs @@ -878,7 +878,7 @@ private static Folder ComputeMergedSolutionExplorerRoot( var orderedConfigs = configObjRoots.Keys.OrderBy(c => c, StringComparer.Ordinal).ToArray(); var root = new Folder(); - var tagsByAssembly = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var tagsByAssembly = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var assemblyId in projectNames) { tagsByAssembly[assemblyId] = ReadRepoAndSolutionName(configObjRoots, orderedConfigs, primaryConfig, assemblyId); @@ -890,17 +890,17 @@ private static Folder ComputeMergedSolutionExplorerRoot( // (or, within a repo, more than one solution), so single-repo/untagged config-merged sites // stay exactly as before this grouping feature existed. var distinctRepoCount = tagsByAssembly.Values - .Select(t => t.Item1) + .Select(t => t.Repo) .Where(r => !string.IsNullOrEmpty(r)) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); var solutionCountsByRepo = tagsByAssembly.Values - .Where(t => !string.IsNullOrEmpty(t.Item1)) - .GroupBy(t => t.Item1, StringComparer.OrdinalIgnoreCase) + .Where(t => !string.IsNullOrEmpty(t.Repo)) + .GroupBy(t => t.Repo, StringComparer.OrdinalIgnoreCase) .ToDictionary( g => g.Key, - g => g.Select(t => t.Item2).Where(s => !string.IsNullOrEmpty(s)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + g => g.Select(t => t.Solution).Where(s => !string.IsNullOrEmpty(s)).Distinct(StringComparer.OrdinalIgnoreCase).Count(), StringComparer.OrdinalIgnoreCase); foreach (var assemblyId in projectNames) @@ -927,9 +927,9 @@ private static Folder ComputeMergedSolutionExplorerRoot( } } - var (repoName, solutionName) = tagsByAssembly[assemblyId]; + var (repoName, solutionName, repoChain) = tagsByAssembly[assemblyId]; - var folder = Program.GetSolutionExplorerGroupingFolder(root, repoName, solutionName, distinctRepoCount, solutionCountsByRepo); + var folder = Program.GetSolutionExplorerGroupingFolder(root, repoChain, solutionName, distinctRepoCount, solutionCountsByRepo); if (folderChain != null) { foreach (var segment in folderChain) @@ -943,11 +943,12 @@ private static Folder ComputeMergedSolutionExplorerRoot( // ProjectSkeleton.Name only affects sort order in the non-flattened tree (WriteFolder // renders by AssemblyName regardless) -- the assemblyId is a reasonable stand-in for the - // Roslyn project display name we no longer have access to at merge time. RepoName is read - // back from the same per-project ProjectInfo.txt (Constants.ProjectInfoFileName) that the - // ordinary single-config ProjectFinalizer.ReadProjectInfo reads, so /repoPath tags survive - // the config merge instead of silently dropping out of SolutionExplorer.html. - folder.Add(new ProjectSkeleton(assemblyId, assemblyId, repoName)); + // Roslyn project display name we no longer have access to at merge time. RepoName/RepoChain + // are read back from the same per-project ProjectInfo.txt (Constants.ProjectInfoFileName) + // that the ordinary single-config ProjectFinalizer.ReadProjectInfo reads, so /repoPath + // tags (and their ancestry) survive the config merge instead of dropping out of + // SolutionExplorer.html. + folder.Add(new ProjectSkeleton(assemblyId, assemblyId, repoName, repoChain)); } return root; @@ -961,7 +962,7 @@ private static Folder ComputeMergedSolutionExplorerRoot( /// the folder-chain lookup above -- a project's tags shouldn't disappear just because the primary /// config happens to lack this file (e.g. the non-primary-only-project case). /// - private static Tuple ReadRepoAndSolutionName( + private static (string Repo, string Solution, string[] Chain) ReadRepoAndSolutionName( IReadOnlyDictionary configObjRoots, IReadOnlyList orderedConfigs, string primaryConfig, @@ -980,10 +981,10 @@ private static Tuple ReadRepoAndSolutionName( } } - return tags ?? Tuple.Create(string.Empty, string.Empty); + return tags ?? (string.Empty, string.Empty, System.Array.Empty()); } - private static Tuple ReadRepoAndSolutionNameFromConfig(string configObjRoot, string assemblyId) + private static (string Repo, string Solution, string[] Chain)? ReadRepoAndSolutionNameFromConfig(string configObjRoot, string assemblyId) { var projectInfoFile = Path.Combine(configObjRoot, assemblyId, Constants.ProjectInfoFileName + ".txt"); if (!File.Exists(projectInfoFile)) @@ -992,9 +993,15 @@ private static Tuple ReadRepoAndSolutionNameFromConfig(string co } var lines = File.ReadAllLines(projectInfoFile); - return Tuple.Create( - Serialization.ReadValue(lines, "RepoName") ?? string.Empty, - Serialization.ReadValue(lines, "SolutionName") ?? string.Empty); + var repo = Serialization.ReadValue(lines, "RepoName") ?? string.Empty; + var chainValue = Serialization.ReadValue(lines, "RepoChain") ?? string.Empty; + var chain = string.IsNullOrEmpty(chainValue) + ? (string.IsNullOrEmpty(repo) ? System.Array.Empty() : new[] { repo }) + : chainValue.Split('|'); + return ( + repo, + Serialization.ReadValue(lines, "SolutionName") ?? string.Empty, + chain); } /// @@ -1077,6 +1084,7 @@ private static void RewriteProjectMapReferencingCounts( { var assembliesAndProjects = new List>(); var repoAndSolutionNamesByAssembly = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var repoChainByAssembly = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var assemblyId in projectNames) { var projectInfoFile = Path.Combine(websiteDestinationFolder, assemblyId, Constants.ProjectInfoFileName + ".txt"); @@ -1088,6 +1096,7 @@ private static void RewriteProjectMapReferencingCounts( repoAndSolutionNamesByAssembly[assemblyId] = Tuple.Create( Serialization.ReadValue(lines, "RepoName") ?? "", Serialization.ReadValue(lines, "SolutionName") ?? ""); + repoChainByAssembly[assemblyId] = Serialization.ReadValue(lines, "RepoChain") ?? ""; } assembliesAndProjects.Add(Tuple.Create(assemblyId, projectInfoLine)); @@ -1098,7 +1107,7 @@ private static void RewriteProjectMapReferencingCounts( // already wrote into this same Assemblies.txt -- otherwise this second write silently wipes // them back out, exactly the kind of drop ComputeMergedSolutionExplorerRoot's ReadRepoName fix // was written to prevent for SolutionExplorer.html. - Serialization.WriteProjectMap(websiteDestinationFolder, assembliesAndProjects, mergedReferencingCounts.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), repoAndSolutionNamesByAssembly); + Serialization.WriteProjectMap(websiteDestinationFolder, assembliesAndProjects, mergedReferencingCounts.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), repoAndSolutionNamesByAssembly, repoChainByAssembly); } } } diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ProjectFinalizer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ProjectFinalizer.cs index 056ace2..c7ce943 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ProjectFinalizer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/ProjectFinalizer.cs @@ -135,6 +135,10 @@ public void ReadDeclarationLines() public string RepoName { get; private set; } = string.Empty; public string SolutionName { get; private set; } = string.Empty; + /// Full repo ancestry ('|'-joined, outermost first) read back from ProjectInfo.txt; + /// falls back to for indexes generated before chains were persisted. + public string RepoChain { get; private set; } = string.Empty; + private void ReadBaseMembers() { var baseMembersFile = Path.Combine(ProjectDestinationFolder, Constants.BaseMembersFileName + ".txt"); @@ -188,6 +192,7 @@ private void ReadProjectInfo() PublicTypeCount = Serialization.ReadLong(lines, "PublicTypes"); RepoName = Serialization.ReadValue(lines, "RepoName"); SolutionName = Serialization.ReadValue(lines, "SolutionName"); + RepoChain = Serialization.ReadValue(lines, "RepoChain"); } var referenceList = Path.Combine(ProjectDestinationFolder, Constants.ReferencedAssemblyList + ".txt"); diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs index c3c94df..235e89f 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.SolutionExplorer.cs @@ -68,7 +68,8 @@ private void WriteFolder(Folder folder, StreamWriter writer) var dataRepoAttribute = string.IsNullOrEmpty(subfolder.RepoName) ? "" - : string.Format(" data-repo=\"{0}\"", subfolder.RepoName); + : string.Format(" data-repo=\"{0}\" data-repo-path=\"{1}\"", + subfolder.RepoName, FormatRepoPath(subfolder.RepoChain, subfolder.RepoName)); writer.WriteLine( @"
{2}
", @@ -82,14 +83,22 @@ private void WriteFolder(Folder folder, StreamWriter writer) { foreach (var project in folder.Items) { - WriteProject(project.AssemblyName, project.RepoName, writer); + WriteProject(project.AssemblyName, project.RepoName, project.RepoChain, writer); } } } - private void WriteProject(string assemblyName, string repoName, StreamWriter writer) + // Repo ancestry ('|'-joined, outermost first) baked into the DOM so the client-side filter can + // scope by ancestor-or-self: selecting a parent repo keeps its nested sub-repos visible. + internal static string FormatRepoPath(System.Collections.Generic.IReadOnlyList repoChain, string repoName) { - var projectExplorerText = GetProjectExplorerText(assemblyName, repoName); + var path = (repoChain != null && repoChain.Count > 0) ? string.Join("|", repoChain) : repoName; + return WebUtility.HtmlEncode(path ?? ""); + } + + private void WriteProject(string assemblyName, string repoName, System.Collections.Generic.IReadOnlyList repoChain, StreamWriter writer) + { + var projectExplorerText = GetProjectExplorerText(assemblyName, repoName, repoChain); if (string.IsNullOrEmpty(projectExplorerText)) { return; @@ -171,7 +180,7 @@ private bool TryWriteProjectSubtreeFragment(string assemblyName, string subtreeI } } - private string GetProjectExplorerText(string assemblyName, string repoName) + private string GetProjectExplorerText(string assemblyName, string repoName, System.Collections.Generic.IReadOnlyList repoChain = null) { var fileName = Path.Combine(SolutionDestinationFolder, assemblyName, Constants.ProjectExplorer + ".html"); if (!File.Exists(fileName)) @@ -188,10 +197,12 @@ private string GetProjectExplorerText(string assemblyName, string repoName) // Only add a data-repo attribute (and thus a client-side filtering hook) when the site // actually has a repo tag -- keeps the untagged/single-repo output identical to before - // repo tagging existed. + // repo tagging existed. data-repo-path carries the full ancestry so a parent repo filter + // includes its nested sub-repos. + var repoPath = FormatRepoPath(repoChain, repoName); var folderAttributes = string.IsNullOrEmpty(repoName) ? string.Format("class=\"folder\" data-assembly=\"{0}\"", assemblyName) - : string.Format("class=\"folder\" data-assembly=\"{0}\" data-repo=\"{1}\"", assemblyName, repoName); + : string.Format("class=\"folder\" data-assembly=\"{0}\" data-repo=\"{1}\" data-repo-path=\"{2}\"", assemblyName, repoName, repoPath); text = text.Replace("
", string.Format("
", folderAttributes)); // The project title (the always-visible "Project2"-style line) is a sibling that comes @@ -199,8 +210,8 @@ private string GetProjectExplorerText(string assemblyName, string repoName) // data-repo attribute or hiding the folder above leaves the title behind in the list. if (!string.IsNullOrEmpty(repoName)) { - text = text.Replace("class=\"projectCS\"", string.Format("class=\"projectCS\" data-repo=\"{0}\"", repoName)); - text = text.Replace("class=\"projectVB\"", string.Format("class=\"projectVB\" data-repo=\"{0}\"", repoName)); + text = text.Replace("class=\"projectCS\"", string.Format("class=\"projectCS\" data-repo=\"{0}\" data-repo-path=\"{1}\"", repoName, repoPath)); + text = text.Replace("class=\"projectVB\"", string.Format("class=\"projectVB\" data-repo=\"{0}\" data-repo-path=\"{1}\"", repoName, repoPath)); } text = text.Replace("projectCS", "projectCSInSolution"); diff --git a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs index 144f698..16e723a 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Pass2-Finalization/SolutionFinalizer.cs @@ -420,7 +420,8 @@ public void CreateProjectMap(string outputPath = null) outputPath ?? SolutionDestinationFolder, projects.Select(p => Tuple.Create(p.AssemblyId, p.ProjectInfoLine)), projects.ToDictionary(p => p.AssemblyId, p => p.ReferencingAssemblies.Count), - projects.ToDictionary(p => p.AssemblyId, p => Tuple.Create(p.RepoName ?? "", p.SolutionName ?? ""))); + projects.ToDictionary(p => p.AssemblyId, p => Tuple.Create(p.RepoName ?? "", p.SolutionName ?? "")), + projects.ToDictionary(p => p.AssemblyId, p => p.RepoChain ?? "")); } public void CreateMasterDeclarationsIndex(string outputPath = null) diff --git a/src/SourceBrowser/src/HtmlGenerator/Program.cs b/src/SourceBrowser/src/HtmlGenerator/Program.cs index 3bc7fcc..9936dec 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Program.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Program.cs @@ -346,16 +346,20 @@ private static async Task IndexSolutionsAsync( // Solution tag is auto-derived from each top-level input's file name when it's a // .sln/.slnx; standalone project/binlog inputs aren't part of a solution, so they - // stay untagged. Repo tag is resolved by longest-prefix match of each input's folder - // against /repoPath (or /repo) mappings; untagged when no mapping applies. Resolve - // both up front (rather than per-iteration below) so we know, before building any - // folder, whether the merged site actually spans more than one repo/solution. + // stay untagged. Repo tag is resolved per project (see Program.ResolveRepoName): the + // most specific /repoPath (or /repo) mapping containing that project's own folder wins, + // so a single VMR-style input can span many sub-repos. The per-input repo tag below is + // only the fallback for projects not under any nested mapping. Solution counts stay + // per-input (used only to decide whether a repo needs Solution sub-folders). var pathTags = solutionFilePaths .Select(path => (Path: path, RepoName: GetRepoName(path, repoPathMappings), SolutionName: GetSolutionName(path))) .ToList(); - var distinctRepoCount = pathTags - .Select(t => t.RepoName) + // Grouping/filtering keys off the full declared repo set, not just the per-input tags: a + // single input (e.g. dotnet/dotnet) can itself span many /repoPath sub-repos, so counting + // inputs alone would collapse them to one and suppress grouping. All repo names come from + // /repo|/repoPath, so the distinct mapping values are exactly that set. + var distinctRepoCount = (repoPathMappings?.Values ?? Enumerable.Empty()) .Where(r => !string.IsNullOrEmpty(r)) .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); @@ -370,13 +374,12 @@ private static async Task IndexSolutionsAsync( foreach (var (path, repoName, solutionName) in pathTags) { - // Only introduce Repo/Solution grouping folders when the merged site actually has - // more than one repo (or, within a repo, more than one solution) to distinguish -- - // keeps single-repo/untagged sites' Solution Explorer tree byte-identical to before - // repo tagging existed. Untagged inputs stay flat at the top level even on a - // multi-repo site, alongside the repo folders. - var solutionFolder = GetSolutionExplorerGroupingFolder( - mergedSolutionExplorerRoot, repoName, solutionName, distinctRepoCount, solutionCountsByRepo); + // The base folder each input's projects attach under. Repo/Solution grouping is applied + // per project downstream (SolutionGenerator/GenerateFromBuildLog) from each project's own + // resolved repo, so a single input can fan its projects out across several repo folders. + // repoName/solutionName here are the per-input fallback tags for projects not under any + // nested /repoPath mapping. + var inputRoot = mergedSolutionExplorerRoot; if (rootPath is object) { @@ -385,7 +388,7 @@ private static async Task IndexSolutionsAsync( foreach (var segment in segments) { - solutionFolder = solutionFolder.GetOrCreateFolder(segment); + inputRoot = inputRoot.GetOrCreateFolder(segment); } } @@ -416,11 +419,14 @@ await GenerateFromBuildLog.GenerateInvocationAsync( serverPathMappings, processedAssemblyList, assemblyNames, - solutionFolder, + inputRoot, typeForwards, includeSourceGeneratedDocuments: includeSourceGeneratedDocuments, repoName: repoName, - solutionName: solutionName); + solutionName: solutionName, + repoPathMappings: repoPathMappings, + distinctRepoCount: distinctRepoCount, + solutionCountsByRepo: solutionCountsByRepo); } continue; @@ -451,9 +457,12 @@ await GenerateFromBuildLog.GenerateInvocationAsync( solutionGenerator.GlobalAssemblyList = assemblyNames; solutionGenerator.RepoName = repoName; solutionGenerator.SolutionName = solutionName; + solutionGenerator.RepoPathMappings = repoPathMappings; + solutionGenerator.DistinctRepoCount = distinctRepoCount; + solutionGenerator.SolutionCountsByRepo = solutionCountsByRepo; using (Disposable.Timing("Pass1 writing for " + path)) { - await solutionGenerator.GenerateAsync(cancellationToken, processedAssemblyList, solutionFolder); + await solutionGenerator.GenerateAsync(cancellationToken, processedAssemblyList, inputRoot); } } } @@ -509,21 +518,51 @@ public static Folder GetSolutionExplorerGroupingFolder( string solutionName, int distinctRepoCount, IReadOnlyDictionary solutionCountsByRepo) + { + var chain = string.IsNullOrEmpty(repoName) ? Array.Empty() : new[] { repoName }; + return GetSolutionExplorerGroupingFolder(root, chain, solutionName, distinctRepoCount, solutionCountsByRepo); + } + + /// Chain-aware overload: is a project's repo ancestry + /// (outermost repo first, the project's own repo last), so a VMR sub-repo (e.g. dotnet/subx + /// nested under dotnet/vmr) is grouped UNDER its parent repo folder rather than as a flat + /// sibling. The innermost repo drives the Solution sub-folder decision. Each repo folder + /// carries its own ancestry so the client-side filter can scope by ancestor-or-self. + public static Folder GetSolutionExplorerGroupingFolder( + Folder root, + IReadOnlyList repoChain, + string solutionName, + int distinctRepoCount, + IReadOnlyDictionary solutionCountsByRepo) { var folder = root; - if (distinctRepoCount > 1 && !string.IsNullOrEmpty(repoName)) + if (distinctRepoCount > 1 && repoChain != null && repoChain.Count > 0) { - folder = folder.GetOrCreateFolder(repoName); - folder.Kind = FolderKind.Repo; - folder.RepoName = repoName; + var prefix = new List(repoChain.Count); + foreach (var repo in repoChain) + { + if (string.IsNullOrEmpty(repo)) + { + continue; + } - if (solutionCountsByRepo.TryGetValue(repoName, out var solutionCount) && + prefix.Add(repo); + folder = folder.GetOrCreateFolder(repo); + folder.Kind = FolderKind.Repo; + folder.RepoName = repo; + folder.RepoChain = prefix.ToArray(); + } + + var innermost = repoChain[repoChain.Count - 1]; + if (!string.IsNullOrEmpty(innermost) && + solutionCountsByRepo.TryGetValue(innermost, out var solutionCount) && solutionCount > 1 && !string.IsNullOrEmpty(solutionName)) { folder = folder.GetOrCreateFolder(solutionName); folder.Kind = FolderKind.Solution; - folder.RepoName = repoName; + folder.RepoName = innermost; + folder.RepoChain = repoChain; } } @@ -541,7 +580,7 @@ private static string GetSolutionName(string path) return string.Empty; } - private static string GetRepoName(string path, IReadOnlyDictionary repoPathMappings) + public static string GetRepoName(string path, IReadOnlyDictionary repoPathMappings) { if (repoPathMappings == null || repoPathMappings.Count == 0) { @@ -568,6 +607,64 @@ private static string GetRepoName(string path, IReadOnlyDictionaryResolves a single project's repo tag: the most specific /repoPath (or /repo) mapping + /// containing that project's own folder wins (longest-prefix), falling back to the whole input's + /// tag when no nested mapping applies. This is what lets a VMR-style input (e.g. dotnet/dotnet) + /// tag each sub-repo project (src/arcade -> dotnet/arcade, ...) instead of stamping the parent + /// repo onto everything.
+ public static string ResolveRepoName(string projectFilePath, IReadOnlyDictionary repoPathMappings, string fallbackRepoName) + { + var resolved = GetRepoName(projectFilePath, repoPathMappings); + return !string.IsNullOrEmpty(resolved) ? resolved : (fallbackRepoName ?? string.Empty); + } + + /// Resolves a single project's full repo ancestry: every /repoPath (or /repo) mapping + /// whose folder contains the project, ordered outermost (least specific) to innermost (the + /// project's own repo, == ). This is what lets a parent repo (e.g. + /// dotnet/vmr) include its nested sub-repos (dotnet/subx, ...) both in the Solution Explorer + /// (nested folders) and in filtering (ancestor-or-self). Falls back to a single-element chain of + /// when no nested mapping applies; empty when untagged. + public static IReadOnlyList ResolveRepoChain(string projectFilePath, IReadOnlyDictionary repoPathMappings, string fallbackRepoName) + { + var chain = GetRepoChain(projectFilePath, repoPathMappings); + if (chain.Count > 0) + { + return chain; + } + + return string.IsNullOrEmpty(fallbackRepoName) ? Array.Empty() : new[] { fallbackRepoName }; + } + + private static List GetRepoChain(string path, IReadOnlyDictionary repoPathMappings) + { + var chain = new List(); + if (repoPathMappings == null || repoPathMappings.Count == 0) + { + return chain; + } + + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directory)) + { + return chain; + } + + // Shortest-prefix first == outermost repo first; nested mappings append their more specific + // repo, so the project's own repo lands last. + foreach (var candidate in repoPathMappings.Keys + .Where(k => Paths.IsOrContains(k, directory)) + .OrderBy(k => k.Length)) + { + var repo = repoPathMappings[candidate]; + if (!string.IsNullOrEmpty(repo) && !chain.Contains(repo, StringComparer.OrdinalIgnoreCase)) + { + chain.Add(repo); + } + } + + return chain; + } + private static void FinalizeProjects(bool emitAssemblyList, Federation federation) { GenerateLooseFilesProject(Constants.MSBuildFiles, Paths.SolutionDestinationFolder); diff --git a/src/SourceBrowser/src/HtmlGenerator/ProjectSkeleton.cs b/src/SourceBrowser/src/HtmlGenerator/ProjectSkeleton.cs index 4e1b229..340af5a 100644 --- a/src/SourceBrowser/src/HtmlGenerator/ProjectSkeleton.cs +++ b/src/SourceBrowser/src/HtmlGenerator/ProjectSkeleton.cs @@ -9,11 +9,17 @@ public class ProjectSkeleton /// be filtered client-side; empty for untagged sites (see SolutionGenerator.RepoName). public string RepoName { get; } - public ProjectSkeleton(string assemblyName, string name, string repoName = "") + /// Full repo ancestry (outermost first, this project's own repo last) so the client + /// filter can scope a parent repo to include its nested sub-repos. Single-element for a + /// non-nested repo; empty for untagged sites. + public System.Collections.Generic.IReadOnlyList RepoChain { get; } + + public ProjectSkeleton(string assemblyName, string name, string repoName = "", System.Collections.Generic.IReadOnlyList repoChain = null) { AssemblyName = assemblyName; Name = name; RepoName = repoName ?? ""; + RepoChain = repoChain ?? (string.IsNullOrEmpty(RepoName) ? System.Array.Empty() : new[] { RepoName }); } } } \ No newline at end of file diff --git a/src/SourceBrowser/src/HtmlGenerator/Properties/launchSettings.json b/src/SourceBrowser/src/HtmlGenerator/Properties/launchSettings.json index 35a628d..ea58860 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Properties/launchSettings.json +++ b/src/SourceBrowser/src/HtmlGenerator/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "HtmlGenerator": { "commandName": "Project", - "commandLineArgs": "$(SolutionDir)TestCode\\TestSolution.sln $(SolutionDir)TestCode\\RepoB\\RepoB.slnx /out:$(SolutionDir)src\\SourceIndexServer\\TestSite /force /config:windows /configAxes:os=windows /p:DefineConstants=WINDOWS /repoPath:\"$(SolutionDir)TestCode\\RepoB\"=\"RepoB\" /repoPath:\"$(SolutionDir)TestCode\"=\"RepoA\"" + "commandLineArgs": "$(SolutionDir)TestCode\\TestSolution.sln $(SolutionDir)TestCode\\RepoB\\RepoB.slnx $(SolutionDir)TestCode\\RepoB\\RepoB2.slnx $(SolutionDir)TestCode\\Vmr\\Vmr.slnx /out:$(SolutionDir)src\\SourceIndexServer\\TestSite /force /config:windows /configAxes:os=windows /p:DefineConstants=WINDOWS /repoPath:\"$(SolutionDir)TestCode\\RepoB\"=\"RepoB\" /repoPath:\"$(SolutionDir)TestCode\"=\"RepoA\" /repoPath:\"$(SolutionDir)TestCode\\Vmr\"=\"dotnet/vmr\" /repoPath:\"$(SolutionDir)TestCode\\Vmr\\src\\SubRepoX\"=\"dotnet/subx\" /repoPath:\"$(SolutionDir)TestCode\\Vmr\\src\\SubRepoY\"=\"dotnet/suby\"" } } } \ No newline at end of file diff --git a/src/SourceBrowser/src/HtmlGenerator/Utilities/Folder`1.cs b/src/SourceBrowser/src/HtmlGenerator/Utilities/Folder`1.cs index a129b13..e8426c3 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Utilities/Folder`1.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Utilities/Folder`1.cs @@ -24,6 +24,11 @@ public class Folder /// plain physical-folder nodes. public string RepoName { get; set; } + /// Full repo ancestry (outermost first, this folder's own repo last) for Repo grouping + /// nodes, so the client-side filter can scope by ancestor-or-self (a parent repo includes its + /// nested sub-repos). Unset for plain physical-folder nodes. + public IReadOnlyList RepoChain { get; set; } + public SortedList> Folders { get; set; } public List Items { get; set; } diff --git a/src/SourceBrowser/src/HtmlGenerator/Utilities/Serialization.cs b/src/SourceBrowser/src/HtmlGenerator/Utilities/Serialization.cs index e973163..e1f3fb4 100644 --- a/src/SourceBrowser/src/HtmlGenerator/Utilities/Serialization.cs +++ b/src/SourceBrowser/src/HtmlGenerator/Utilities/Serialization.cs @@ -230,7 +230,8 @@ public static void WriteProjectMap( string outputPath, IEnumerable> listOfAssemblyNamesAndProjects, IDictionary referencingAssembliesCount, - IDictionary> repoAndSolutionNamesByAssembly = null) + IDictionary> repoAndSolutionNamesByAssembly = null, + IDictionary repoChainByAssembly = null) { IEnumerable> assemblies; IEnumerable projects; @@ -241,7 +242,7 @@ public static void WriteProjectMap( using (Measure.Time("Writing project map")) { - // Only emit the extra ;repo;solution fields when at least one assembly in the site + // Only emit the extra ;repo;solution;chain fields when at least one assembly in the site // actually carries a non-empty tag, so an untagged/single-repo site's Assemblies.txt // stays byte-identical to the format written before repo/solution tagging existed. bool anyTagged = repoAndSolutionNamesByAssembly != null && @@ -266,7 +267,16 @@ public static void WriteProjectMap( solutionName = tags.Item2 ?? ""; } - line = line + ";" + repoName + ";" + solutionName; + // Repo ancestry ('|'-joined), so the server can scope a parent repo to include + // its nested sub-repos. Defaults to the leaf repo when no chain was persisted. + string repoChain = ""; + repoChainByAssembly?.TryGetValue(t.Item1, out repoChain); + if (string.IsNullOrEmpty(repoChain)) + { + repoChain = repoName; + } + + line = line + ";" + repoName + ";" + solutionName + ";" + repoChain; } return line; diff --git a/src/SourceBrowser/src/SourceIndexServer.Tests/IndexUnitTests.cs b/src/SourceBrowser/src/SourceIndexServer.Tests/IndexUnitTests.cs index 20bfe24..8a23172 100644 --- a/src/SourceBrowser/src/SourceIndexServer.Tests/IndexUnitTests.cs +++ b/src/SourceBrowser/src/SourceIndexServer.Tests/IndexUnitTests.cs @@ -350,6 +350,40 @@ public void TestRepoFilter_ScopesResultsToTheSelectedRepo() } } + [TestMethod] + public void TestRepoFilter_ParentRepoIncludesItsNestedSubRepos() + { + using (var index = new Index()) + { + var testData = new List + { + new DeclaredSymbolInfo { Name = "WidgetVmr", Description = "V.WidgetVmr", AssemblyNumber = 0 }, + new DeclaredSymbolInfo { Name = "WidgetSubX", Description = "X.WidgetSubX", AssemblyNumber = 1 }, + new DeclaredSymbolInfo { Name = "WidgetOther", Description = "O.WidgetOther", AssemblyNumber = 2 }, + }; + + var huffman = Huffman.Create(testData.Select(d => d.Description)); + index.indexFinishedPopulating = true; + index.huffman = huffman; + index.symbols = testData.Select(dsi => new IndexEntry(dsi)).ToList(); + index.PopulateSymbolsById(); + index.assemblies = new List + { + new AssemblyInfo { AssemblyName = "V", ProjectKey = 0, RepoName = "dotnet/vmr", SolutionName = "Vmr", RepoChain = new[] { "dotnet/vmr" } }, + new AssemblyInfo { AssemblyName = "X", ProjectKey = 1, RepoName = "dotnet/subx", SolutionName = "SubX", RepoChain = new[] { "dotnet/vmr", "dotnet/subx" } }, + new AssemblyInfo { AssemblyName = "O", ProjectKey = 2, RepoName = "dotnet/other", SolutionName = "Other", RepoChain = new[] { "dotnet/other" } }, + }; + + // Selecting the parent repo pulls in its nested sub-repo, but not an unrelated repo. + var parent = index.Get("repo:dotnet/vmr Widget"); + CollectionAssert.AreEquivalent(new[] { "WidgetVmr", "WidgetSubX" }, parent.ResultSymbols.Select(s => s.Name).ToArray()); + + // Selecting the sub-repo directly stays scoped to just that sub-repo. + var child = index.Get("repo:dotnet/subx Widget"); + CollectionAssert.AreEqual(new[] { "WidgetSubX" }, child.ResultSymbols.Select(s => s.Name).ToArray()); + } + } + public void Test(IEnumerable> input, string pattern, params string[] expectedResults) { using (var index = new Index()) diff --git a/src/SourceBrowser/src/SourceIndexServer/Models/AssemblyInfo.cs b/src/SourceBrowser/src/SourceIndexServer/Models/AssemblyInfo.cs index 66f079d..3f7e115 100644 --- a/src/SourceBrowser/src/SourceIndexServer/Models/AssemblyInfo.cs +++ b/src/SourceBrowser/src/SourceIndexServer/Models/AssemblyInfo.cs @@ -14,6 +14,11 @@ public struct AssemblyInfo public string RepoName; public string SolutionName; + /// Full repo ancestry (outermost first, own repo last), so a parent repo filter can + /// include its nested sub-repos. Falls back to a single-element chain of + /// for indexes generated before chains were persisted. + public string[] RepoChain; + public AssemblyInfo(string line) { var parts = line.Split(';'); @@ -22,6 +27,9 @@ public AssemblyInfo(string line) ReferencingAssembliesCount = short.Parse(parts[2]); RepoName = parts.Length > 3 ? parts[3] : ""; SolutionName = parts.Length > 4 ? parts[4] : ""; + RepoChain = parts.Length > 5 && parts[5].Length > 0 + ? parts[5].Split('|') + : (RepoName.Length > 0 ? new[] { RepoName } : System.Array.Empty()); } } } diff --git a/src/SourceBrowser/src/SourceIndexServer/Models/Index.cs b/src/SourceBrowser/src/SourceIndexServer/Models/Index.cs index aa6128f..188ec62 100644 --- a/src/SourceBrowser/src/SourceIndexServer/Models/Index.cs +++ b/src/SourceBrowser/src/SourceIndexServer/Models/Index.cs @@ -169,7 +169,7 @@ public AssemblyInfo FindAssembly(string assemblyName) public IReadOnlyList GetDistinctRepoNames() { return assemblies - .Select(a => a.RepoName) + .SelectMany(a => a.RepoChain ?? (string.IsNullOrEmpty(a.RepoName) ? System.Array.Empty() : new[] { a.RepoName })) .Where(r => !string.IsNullOrEmpty(r)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(r => r, StringComparer.OrdinalIgnoreCase) diff --git a/src/SourceBrowser/src/SourceIndexServer/Models/Query.cs b/src/SourceBrowser/src/SourceIndexServer/Models/Query.cs index 597361e..78f5fa2 100644 --- a/src/SourceBrowser/src/SourceIndexServer/Models/Query.cs +++ b/src/SourceBrowser/src/SourceIndexServer/Models/Query.cs @@ -284,7 +284,7 @@ private bool FilterRepoAndSolution(string assemblyName) var assemblyInfo = AssemblyResolver(assemblyName); - if (this.Repos.Any() && !this.Repos.Any(r => string.Equals(r, assemblyInfo.RepoName, StringComparison.OrdinalIgnoreCase))) + if (this.Repos.Any() && !RepoMatches(assemblyInfo)) { return false; } @@ -297,6 +297,20 @@ private bool FilterRepoAndSolution(string assemblyName) return true; } + // A selected repo matches an assembly when it's the assembly's own repo OR any of its ancestors + // (its RepoChain) -- so filtering by a parent repo (e.g. dotnet/vmr) includes its nested + // sub-repos, while filtering by a leaf repo stays scoped to just that repo. + private bool RepoMatches(AssemblyInfo assemblyInfo) + { + var chain = assemblyInfo.RepoChain; + if (chain == null || chain.Length == 0) + { + return this.Repos.Any(r => string.Equals(r, assemblyInfo.RepoName, StringComparison.OrdinalIgnoreCase)); + } + + return this.Repos.Any(r => chain.Any(c => string.Equals(r, c, StringComparison.OrdinalIgnoreCase))); + } + /// /// Same repo/solution scoping as , applied directly /// to an AssemblyInfo (used for assembly/project results, which don't go through @@ -309,7 +323,7 @@ public bool FilterAssembly(AssemblyInfo assemblyInfo) return true; } - if (this.Repos.Any() && !this.Repos.Any(r => string.Equals(r, assemblyInfo.RepoName, StringComparison.OrdinalIgnoreCase))) + if (this.Repos.Any() && !RepoMatches(assemblyInfo)) { return false; } diff --git a/src/SourceBrowser/src/SourceIndexServer/wwwroot/scripts.js b/src/SourceBrowser/src/SourceIndexServer/wwwroot/scripts.js index b94bfac..10cc554 100644 --- a/src/SourceBrowser/src/SourceIndexServer/wwwroot/scripts.js +++ b/src/SourceBrowser/src/SourceIndexServer/wwwroot/scripts.js @@ -901,24 +901,56 @@ function updateGroupHeaderOffset(note) { } // Hides/shows each project's subtree in the merged Solution Explorer (SolutionExplorer.html) -// based on its data-repo attribute (see SolutionFinalizer.GetProjectExplorerText), which is only -// emitted at all when the site has a repo tag. A "repoHidden" CSS class (rather than toggling -// inline display directly) is used so this doesn't fight with the folder expand/collapse logic, -// which also manipulates display on the same elements. +// based on its data-repo-path attribute (see SolutionFinalizer.GetProjectExplorerText), which is +// only emitted when the site has a repo tag. data-repo-path is the node's full repo ancestry +// ('|'-joined, outermost first), so selecting a parent repo (e.g. dotnet/vmr) keeps its nested +// sub-repos visible: a leaf shows when the selection is on its path; an ancestor grouping folder +// shows when it's on the *selection's* path (so it doesn't hide the matching descendants nested +// inside it). A "repoHidden" CSS class (rather than toggling inline display) is used so this +// doesn't fight the folder expand/collapse logic, which also manipulates display. function filterSolutionExplorerByRepo(repo) { - var nodes = document.querySelectorAll("[data-repo]"); + var nodes = document.querySelectorAll("[data-repo-path]"); + + // The selected repo's own ancestry, read off any node whose innermost repo is the selection. + var selChain = repo ? [repo] : []; + if (repo) { + for (var s = 0; s < nodes.length; s++) { + var segs = nodes[s].getAttribute("data-repo-path").split("|"); + if (segs[segs.length - 1] === repo) { + selChain = segs; + break; + } + } + } + for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; - var hide = !!repo && node.getAttribute("data-repo") !== repo; - node.classList.toggle("repoHidden", hide); + var path = node.getAttribute("data-repo-path").split("|"); + var own = path[path.length - 1]; + + // Leaves (project title rows and project file-tree folders) only show when the selection is + // on their path. Grouping folders (repo/solution wrappers, which have no data-assembly) also + // show when they're an ancestor on the selection's path, so hiding them wouldn't hide the + // matching sub-repos nested within. + var isLeaf = node.hasAttribute("data-assembly") || + node.className.indexOf("projectCSInSolution") !== -1 || + node.className.indexOf("projectVBInSolution") !== -1; + + var visible = !repo || + path.indexOf(repo) !== -1 || + (!isLeaf && selChain.indexOf(own) !== -1); + + node.classList.toggle("repoHidden", !visible); } + hideEmptyGroupingFolders(); + // Once scoped to a single repo, that repo's own grouping header (see // Program.GetSolutionExplorerGroupingFolder -- only emitted when the site spans more than one // repo) is redundant: the user already picked it from the dropdown. Unwrap it -- hide the // header label and force its folder open -- so the tree reads the same as an ungrouped site - // instead of showing an always-selected wrapper around everything. Other repos' headers are - // left alone; they're already fully hidden by the data-repo pass above. + // instead of showing an always-selected wrapper around everything. Nested sub-repo headers are + // left alone so their grouping stays visible underneath. var repoTitles = document.querySelectorAll(".repoTitle"); for (var i = 0; i < repoTitles.length; i++) { var title = repoTitles[i]; @@ -931,6 +963,41 @@ function filterSolutionExplorerByRepo(repo) { } } +// After the per-node repo pass, a grouping folder can be left visible with all of its project rows +// hidden -- most visibly an MSBuild solution folder (e.g. "SolutionFolder"), which carries no +// data-repo-path and so is never touched by the pass above, but also any repo/solution wrapper whose +// matching descendants all live elsewhere. Hide any such now-empty grouping folder (and its title) so +// the filtered tree doesn't show hollow headers. A grouping folder counts as non-empty when it still +// contains a visible project row; file-tree folders inside a project (data-assembly subtree) are left +// alone so a matched project's own folders aren't pruned. +function hideEmptyGroupingFolders() { + var titles = document.querySelectorAll("#rootFolder .folderTitle"); + for (var i = 0; i < titles.length; i++) { + var title = titles[i]; + if (title.id === "rootFolder" || title.closest("[data-assembly]")) { + continue; + } + + var folder = title.nextElementSibling; + if (!folder || folder.className.indexOf("folder") === -1) { + continue; + } + + var projects = folder.querySelectorAll(".projectCSInSolution, .projectVBInSolution"); + var hasVisibleProject = false; + for (var j = 0; j < projects.length; j++) { + if (!projects[j].classList.contains("repoHidden")) { + hasVisibleProject = true; + break; + } + } + + var empty = projects.length > 0 && !hasVisibleProject; + title.classList.toggle("repoHidden", empty); + folder.classList.toggle("repoHidden", empty); + } +} + function onSearchChange() { ensureSearchBox(); if (searchBox.value.length > 2) { @@ -1506,6 +1573,7 @@ function onSolutionExplorerLoad() { function loadSolutionExplorer() { makeFoldersCollapsible(/* closed folder */"202.png", "201.png", "content/icons/", initializeSolutionExplorerFolder); document.getElementById("rootFolder").style.display = "block"; + normalizeGroupingBands(); // Apply whatever repo is currently selected (persisted on `top`) so navigating to the // Solution Explorer after already picking a repo in search stays scoped the same way, even @@ -1514,6 +1582,40 @@ function loadSolutionExplorer() { initRepoFilter(function (repo) { filterSolutionExplorerByRepo(repo); }); } +// A nested repo/solution header sits inside one indented .folder per depth level, so its box starts at +// the indented x and its tint band can't reach the pane's left edge the way the outermost repo's does. +// Pull each header's box back to the leftmost (depth-0) header's x via negative margin, then re-add the +// removed offset as padding so the text/arrow stay at their depth indent. Combined with min-width:100vw +// the band then spans the full pane at every depth, matching the outermost repo. Idempotent: once a +// header is aligned its measured offset is zero, so re-running (e.g. after a theme toggle) is a no-op. +function normalizeGroupingBands() { + var titles = document.querySelectorAll("body.solutionExplorerBody .repoTitle, body.solutionExplorerBody .solutionTitle"); + if (!titles.length) { + return; + } + + var lefts = []; + var minLeft = Infinity; + for (var i = 0; i < titles.length; i++) { + var left = titles[i].getBoundingClientRect().left; + lefts.push(left); + if (left < minLeft) { + minLeft = left; + } + } + + for (var j = 0; j < titles.length; j++) { + var shift = lefts[j] - minLeft; + if (shift <= 0) { + continue; + } + var el = titles[j]; + var cs = getComputedStyle(el); + el.style.marginLeft = (parseFloat(cs.marginLeft) - shift) + "px"; + el.style.paddingLeft = (parseFloat(cs.paddingLeft) + shift) + "px"; + } +} + function initializeNamespaceExplorer() { // The namespace tree ships as a compact JSON payload (namespaceExplorerData) instead of a // multi-MB nested-
document. Build DOM for the top level now and defer each subtree until @@ -1724,6 +1826,12 @@ function expandCollapseFolder(capturedFolder, capturedPlusMinus, capturedFolderI capturedFolder.everExpanded = true; capturedFolder.style.display = 'block'; + + // Nested repo/solution headers are measured for full-width alignment only once visible; + // realign now that this expand may have revealed some (no-op for already-aligned ones). + if (capturedFolder.querySelector(".repoTitle, .solutionTitle")) { + normalizeGroupingBands(); + } } else { capturedPlusMinus.src = pathToIcons + "plus.png"; diff --git a/src/SourceBrowser/src/SourceIndexServer/wwwroot/styles.css b/src/SourceBrowser/src/SourceIndexServer/wwwroot/styles.css index b011357..340fe62 100644 --- a/src/SourceBrowser/src/SourceIndexServer/wwwroot/styles.css +++ b/src/SourceBrowser/src/SourceIndexServer/wwwroot/styles.css @@ -9,6 +9,7 @@ --color-page-background: white; --color-page-foreground: black; --color-pane-border: #aaa; + --color-repo-tint: #eef4fb; /* Selection / focus highlight; identical in light and dark, so it is not overridden in the dark block below. */ @@ -443,7 +444,7 @@ a i { boundaries are visually obvious at a glance, distinct from plain physical folders. */ .repoTitle { font-weight: bold; - background-color: #eef4fb; + background-color: var(--color-repo-tint); border-left: 3px solid #4a90d9; padding-left: 4px; margin-left: -4px; @@ -962,23 +963,25 @@ body.solutionExplorerBody #rootFolder { /* Repo (and, on multi-solution sites, solution) grouping headers stay visible while the tree is scrolled. Sticky top floats the header down as its subtree scrolls past so the current repo stays - labeled; sticky left plus a full-pane min-width keeps the tint spanning the view even after a long - path has scrolled the tree sideways -- otherwise the band would end at the pane's original right - edge. The opaque background (repoTitle already tints; solutionTitle gets one) stops scrolled rows - from showing through the pinned header. */ + labeled. The band must read full-width at every nesting depth like the outermost repo, so + normalizeGroupingBands (scripts.js) pulls each header's box to the pane's left edge and restores the + depth indent as padding (leaving just the text/arrow offset); min-width:100vw then spans the header + across the pane -- sticky left:0 keeps it there when a long path scrolls the tree sideways. The + opaque background (repoTitle already tints; solutionTitle gets one) stops scrolled rows from showing + through the pinned header. */ body.solutionExplorerBody .repoTitle, body.solutionExplorerBody .solutionTitle { position: sticky; top: 0; left: 0; z-index: 1; - min-width: 100%; + min-width: 100vw; width: max-content; box-sizing: border-box; } body.solutionExplorerBody .solutionTitle { - background-color: #ffffff; + background-color: var(--color-page-background); } /* Hides a Solution Explorer project subtree that doesn't match the selected repo filter (see filterSolutionExplorerByRepo in scripts.js). A class rather than inline display is used so this @@ -1659,6 +1662,7 @@ ol a, ol a:link, ol a:hover, ol a:focus, ol a:active, up automatically through the custom properties declared in :root. */ :root { --color-page-background: black; + --color-repo-tint: #1c2b3d; --color-page-foreground: #aaa; --color-pane-border: #333; } @@ -1724,7 +1728,6 @@ ol a, ol a:link, ol a:hover, ol a:focus, ol a:active, } .repoTitle { - background-color: #1c2b3d; border-left-color: #4a90d9; }