(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;
}