in EnvDTE.Host/Callback/Impl/ProjectModelImpl/SolutionSyncListener.cs [21:194]
public class SolutionSyncListener(
Lifetime componentLifetime,
ILogger logger,
ISolution solution,
ProjectModelViewHost modelViewHost)
: SolutionHostSyncListener, IEnvDteCallbackProvider
{
[CanBeNull] private DteProtocolModel _model;
[CanBeNull] private IScheduler _scheduler;
public void RegisterCallbacks(DteProtocolModel model, IScheduler scheduler)
{
_model = model;
_scheduler = scheduler;
/*
* Project loading is delivered as a single “bulk add” update: once the IDE has finished loading, an update event
* is fired whose AddedProjects contains *all* solution projects.
*
* Before that event, the solution effectively has no real projects yet (apart from misc/solution), so 'GetTopLevelProjects()'/'GetAllProjects()'
* will return an empty list (the list will actually contain misc and solution projects, but those are not relevant).
*
* There is no intermediate state where only a subset of loaded projects is visible. As a result, cache initialization is robust:
* - If requestInitialization is called early, it returns an empty list and subsequent events populate the cache.
* - If it is called after loading, it returns the complete project list.
*/
// TODO: Ordering projects in this call is probably not needed, because of the way cache is populated on the client side and the way project loading is performed
_model.ProjectHierarchyCache_requestInitialization.SetAsync((lifetime, _) =>
solution.Locks.ExecuteOrQueueReadLockAsync(lifetime, "EnvDTE.ProjectHierarchyCache.requestInitialization",
() =>
{
// GetAllProjects doesn't guarantee hierarchical ordering
var visitor = new ProjectHierarchyVisitor();
foreach (var topLevel in solution.GetTopLevelProjects())
{
topLevel.Accept(visitor);
}
return visitor.Projects.Select(GetArgs).ToList();
}));
}
public override void BeforeUpdateProjects(ProjectStructureChange change)
{
if (_model is null || _scheduler is null) return;
// Renames are modeled as Remove + Add; We want to ignore them
var addedProjectsSet = change.AddedProjects.ToHashSet(c => c.ProjectMark.Guid);
foreach (var projectChange in change.RemovedProjects)
{
if (addedProjectsSet.Contains(projectChange.ProjectMark.Guid)) continue;
var args = GetArgsForRemoval(projectChange);
if (args is null) continue;
_scheduler.Queue(() => _model.ProjectHierarchyCache_remove_Project(args));
}
}
public override void AfterUpdateProjects(ProjectStructureChange change)
{
if (_model is null || _scheduler is null) return;
// Renames are modeled as Remove + Add; We want to ignore them
var removedProjectSet = change.RemovedProjects.ToHashSet(c => c.ProjectMark.Guid);
// Flatten the hierarchy - parents should be processed before children
var addedInOrder = FlattenHierarchy(change.AddedProjects.Where(c => c.Parent == null));
foreach (var projectChange in addedInOrder)
{
if (removedProjectSet.Contains(projectChange.ProjectMark.Guid)) continue;
var args = GetArgsForAddition(projectChange);
if (args is null) continue;
_scheduler.Queue(() => _model.ProjectHierarchyCache_add_Project(args));
}
foreach (var projectChange in change.UpdatedProjects)
{
var args = GetArgsForAddition(projectChange);
if (args is null) continue;
_scheduler.Queue(() => _model.ProjectHierarchyCache_update_Project(args));
}
}
private static IEnumerable<ProjectHostChange> FlattenHierarchy(IEnumerable<ProjectHostChange> roots)
{
foreach (var root in roots)
{
yield return root;
foreach (var child in FlattenHierarchy(root.Children))
{
yield return child;
}
}
}
/*
* The separate methods exist because of the way 'ProjectModelViewHost' updates item IDs when a project is added or removed.
*
* Add:
* The 'IProjectMark' is registered in the view host before the 'IProject' is registered.
* During the callback, querying the host by 'IProject' can return ID = 0 (not registered yet).
* Therefore, 'GetArgsForAddition' resolves the ID via 'IProjectMark'.
*
* Remove:
* The 'IProjectMark' is unregistered in the view host before the 'IProject' is unregistered.
* During the callback, querying the host by 'IProjectMark' can return ID = 0 (already unregistered).
* Therefore, 'GetArgsForRemoval' resolves the ID via the 'IProject' instance.
*
* Update:
* Both 'IProject' and 'IProjectMark' can be used for ID retrieval.
*/
[CanBeNull]
private ProjectHierarchyCacheEventArgs GetArgsForRemoval(ProjectHostChange change)
{
var project = solution.GetProjectByMark(change.ProjectMark);
if (project is null)
{
logger.Error($"REMOVE: Project not found for mark: {change.ProjectMark.Guid}");
return null;
}
return new ProjectHierarchyCacheEventArgs(
GetProjectItemModel(project),
project.IsCPSProject(componentLifetime),
project.ParentFolder is null ? null : GetProjectItemModel(project.ParentFolder));
}
[CanBeNull]
private ProjectHierarchyCacheEventArgs GetArgsForAddition(ProjectHostChange change)
{
var project = solution.GetProjectByMark(change.ProjectMark);
if (project is null)
{
logger.Error($"ADD: Project not found for mark: {change.ProjectMark.Guid}");
return null;
}
return new ProjectHierarchyCacheEventArgs(
GetProjectItemModel(change.ProjectMark),
project.IsCPSProject(componentLifetime),
change.ProjectMark.Parent is null ? null : GetProjectItemModel(change.ProjectMark.Parent));
}
private ProjectHierarchyCacheEventArgs GetArgs(IProject project) => new(
GetProjectItemModel(project),
project.IsCPSProject(componentLifetime),
project.ParentFolder is null ? null : GetProjectItemModel(project.ParentFolder));
private ProjectItemModel GetProjectItemModel<T>(T item) => new(modelViewHost.GetIdByItem(item));
private class ProjectHierarchyVisitor : RecursiveProjectVisitor
{
private readonly List<IProject> _projects = [];
public IReadOnlyList<IProject> Projects => _projects;
public override void VisitProject(IProject project)
{
if (!project.IsMiscFilesProject() && !project.IsSolutionProject())
{
_projects.Add(project);
}
base.VisitProject(project);
}
}
}