From d981013af39e1cd8e752c3567560a3f5af65bf30 Mon Sep 17 00:00:00 2001 From: Matt Parker <61717342+MattParkerDev@users.noreply.github.com> Date: Sat, 13 Dec 2025 14:31:31 +1000 Subject: [PATCH] populate sln explorer tree in background thread --- .../VsPersistence/SharpIdeModels.cs | 2 +- .../SolutionExplorer/SolutionExplorerPanel.cs | 44 ++++++++++++------- .../SolutionExplorerPanel.tscn | 15 ++++++- src/SharpIDE.Godot/IdeRoot.cs | 2 +- .../addons/R3.Godot/GodotNodeExtensions.cs | 19 ++++++++ 5 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs b/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs index 81a3ceb..2fa46a0 100644 --- a/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs +++ b/src/SharpIDE.Application/Features/SolutionDiscovery/VsPersistence/SharpIdeModels.cs @@ -119,7 +119,7 @@ public class SharpIdeProjectModel : ISharpIdeNode, IExpandableSharpIdeNode, IChi DirectoryPath = Path.GetDirectoryName(projectModel.FullFilePath)!; Files = new ObservableList(TreeMapperV2.GetFiles(projectModel.FullFilePath, this, allFiles)); Folders = new ObservableList(TreeMapperV2.GetSubFolders(projectModel.FullFilePath, this, allFiles, allFolders)); - MsBuildEvaluationProjectTask = ProjectEvaluation.GetProject(projectModel.FullFilePath); + MsBuildEvaluationProjectTask = Task.Run(() => ProjectEvaluation.GetProject(projectModel.FullFilePath)); allProjects.Add(this); } diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index fbda391..5599ada 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -3,6 +3,7 @@ using Ardalis.GuardClauses; using Godot; using ObservableCollections; using R3; +using SharpIDE.Application; using SharpIDE.Application.Features.Analysis; using SharpIDE.Application.Features.NavigationHistory; using SharpIDE.Application.Features.SolutionDiscovery; @@ -27,6 +28,7 @@ public partial class SolutionExplorerPanel : MarginContainer public Texture2D SlnIcon { get; set; } = null!; public SharpIdeSolutionModel SolutionModel { get; set; } = null!; + private PanelContainer _panelContainer = null!; private Tree _tree = null!; private TreeItem _rootItem = null!; @@ -35,8 +37,11 @@ public partial class SolutionExplorerPanel : MarginContainer private (List, ClipboardOperation)? _itemsOnClipboard; public override void _Ready() { - _tree = GetNode("Tree"); + _panelContainer = GetNode("PanelContainer"); + _tree = GetNode("%Tree"); _tree.ItemMouseSelected += TreeOnItemMouseSelected; + // Remove the tree from the scene tree for now, we will add it back when we bind to a solution + _panelContainer.RemoveChild(_tree); GodotGlobalEvents.Instance.FileExternallySelected.Subscribe(OnFileExternallySelected); } @@ -125,11 +130,16 @@ public partial class SolutionExplorerPanel : MarginContainer return null; } - public void BindToSolution() => BindToSolution(SolutionModel); + public async Task BindToSolution() => await BindToSolution(SolutionModel); [RequiresGodotUiThread] - public void BindToSolution(SharpIdeSolutionModel solution) + public async Task BindToSolution(SharpIdeSolutionModel solution) { - _tree.Clear(); + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + using var _ = SharpIdeOtel.Source.StartActivity($"{nameof(SolutionExplorerPanel)}.{nameof(BindToSolution)}"); + + // Solutions with hundreds of thousands of files can cause the ui to freeze as the tree is populated + // the Tree has been removed from the scene tree in _Ready, so we can operate on it off the ui thread, then add it back + _tree.Clear(); // Root var rootItem = _tree.CreateItem(); @@ -147,7 +157,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Add => this.InvokeAsync(() => e.NewItem.View.Value = CreateProjectTreeItem(_tree, _rootItem, e.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(e.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); // Observe Solution Folders var foldersView = solution.SlnFolders.CreateView(y => new TreeItemContainer()); @@ -158,10 +168,14 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Add => this.InvokeAsync(() => e.NewItem.View.Value = CreateSlnFolderTreeItem(_tree, _rootItem, e.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(e.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); rootItem.SetCollapsedRecursive(true); rootItem.Collapsed = false; + await this.InvokeAsync(() => + { + _panelContainer.AddChild(_tree); + }); } [RequiresGodotUiThread] @@ -182,7 +196,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateSlnFolderTreeItem(_tree, folderItem, innerEvent.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); var projectsView = slnFolder.Projects.CreateView(y => new TreeItemContainer()); projectsView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateProjectTreeItem(_tree, folderItem, s.Value)); @@ -192,7 +206,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateProjectTreeItem(_tree, folderItem, innerEvent.NewItem.Value)), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); var filesView = slnFolder.Files.CreateView(y => new TreeItemContainer()); filesView.Unfiltered.ToList().ForEach(s => s.View.Value = CreateFileTreeItem(_tree, folderItem, s.Value)); @@ -202,7 +216,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Add => this.InvokeAsync(() => innerEvent.NewItem.View.Value = CreateFileTreeItem(_tree, folderItem, innerEvent.NewItem.Value, innerEvent.NewStartingIndex)), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); return folderItem; } @@ -225,7 +239,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); // Observe project files var filesView = projectModel.Files.CreateView(y => new TreeItemContainer()); @@ -237,7 +251,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); return projectItem; } @@ -253,7 +267,7 @@ public partial class SolutionExplorerPanel : MarginContainer .Skip(1).SubscribeOnThreadPool().ObserveOnThreadPool().SubscribeAwait(async (s, ct) => { await this.InvokeAsync(() => folderItem.SetText(0, s)); - }).AddTo(this); + }).AddToDeferred(this); // Observe subfolders var subFoldersView = sharpIdeFolder.Folders.CreateView(y => new TreeItemContainer()); @@ -266,7 +280,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); // Observe files var filesView = sharpIdeFolder.Files.CreateView(y => new TreeItemContainer()); @@ -278,7 +292,7 @@ public partial class SolutionExplorerPanel : MarginContainer NotifyCollectionChangedAction.Move => MoveTreeItem(_tree, innerEvent.NewItem.View, innerEvent.NewItem.Value, innerEvent.OldStartingIndex, innerEvent.NewStartingIndex), NotifyCollectionChangedAction.Remove => FreeTreeItem(innerEvent.OldItem.View.Value), _ => Task.CompletedTask - })).AddTo(this); + })).AddToDeferred(this); return folderItem; } @@ -308,7 +322,7 @@ public partial class SolutionExplorerPanel : MarginContainer fileItem.SetText(0, s); fileItem.SetIconsForFileExtension(sharpIdeFile); }); - }).AddTo(this); + }).AddToDeferred(this); return fileItem; } diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn index c25293d..43c4154 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=8 format=3 uid="uid://cy1bb32g7j7dr"] +[gd_scene load_steps=9 format=3 uid="uid://cy1bb32g7j7dr"] [ext_resource type="Script" uid="uid://bai53k7ongbxw" path="res://Features/SolutionExplorer/SolutionExplorerPanel.cs" id="1_gjy0r"] [ext_resource type="Texture2D" uid="uid://do0edciarrnp0" path="res://Features/SolutionExplorer/Resources/CsharpFile.svg" id="2_8ymw0"] @@ -7,6 +7,12 @@ [ext_resource type="Texture2D" uid="uid://cqt30ma6xgder" path="res://Features/SolutionExplorer/Resources/Csproj.svg" id="5_r1qfc"] [ext_resource type="Texture2D" uid="uid://btlxqx3c08fjj" path="res://Features/SolutionExplorer/Resources/SlnIcon.svg" id="6_idvpu"] +[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_idvpu"] +content_margin_left = 4.0 +content_margin_top = 4.0 +content_margin_right = 4.0 +content_margin_bottom = 5.0 + [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_idvpu"] content_margin_left = 4.0 content_margin_top = 4.0 @@ -41,12 +47,17 @@ SlnFolderIcon = ExtResource("4_8ymw0") CsprojIcon = ExtResource("5_r1qfc") SlnIcon = ExtResource("6_idvpu") -[node name="Tree" type="Tree" parent="."] +[node name="PanelContainer" type="PanelContainer" parent="."] +layout_mode = 2 + +[node name="Tree" type="Tree" parent="PanelContainer"] +unique_name_in_owner = true layout_mode = 2 theme_override_colors/font_color = Color(0.830335, 0.830335, 0.830335, 1) theme_override_constants/v_separation = 1 theme_override_constants/inner_item_margin_left = 2 theme_override_constants/draw_guides = 0 +theme_override_styles/panel = SubResource("StyleBoxEmpty_idvpu") theme_override_styles/cursor = SubResource("StyleBoxFlat_idvpu") theme_override_styles/cursor_unfocused = SubResource("StyleBoxFlat_idvpu") allow_rmb_select = true diff --git a/src/SharpIDE.Godot/IdeRoot.cs b/src/SharpIDE.Godot/IdeRoot.cs index 0666236..f194aa4 100644 --- a/src/SharpIDE.Godot/IdeRoot.cs +++ b/src/SharpIDE.Godot/IdeRoot.cs @@ -157,7 +157,7 @@ public partial class IdeRoot : Control _fileExternalChangeHandler.SolutionModel = solutionModel; _fileChangedService.SolutionModel = solutionModel; _sharpIdeSolutionModificationService.SolutionModel = solutionModel; - Callable.From(_solutionExplorerPanel.BindToSolution).CallDeferred(); + _ = Task.GodotRun(_solutionExplorerPanel.BindToSolution); _roslynAnalysis.StartLoadingSolutionInWorkspace(solutionModel); _fileWatcher.StartWatching(solutionModel); diff --git a/src/SharpIDE.Godot/addons/R3.Godot/GodotNodeExtensions.cs b/src/SharpIDE.Godot/addons/R3.Godot/GodotNodeExtensions.cs index 52053ba..5afb806 100644 --- a/src/SharpIDE.Godot/addons/R3.Godot/GodotNodeExtensions.cs +++ b/src/SharpIDE.Godot/addons/R3.Godot/GodotNodeExtensions.cs @@ -29,4 +29,23 @@ public static class GodotNodeExtensions node.TreeExited += () => disposable.Dispose(); return disposable; } + + public static void AddToDeferred(this T disposable, Node node) where T : IDisposable + { + Callable.From(() => + { + // Note: Dispose when tree exited, so if node is not inside tree, dispose immediately. + if (!node.IsInsideTree()) + { + if (!node.IsNodeReady()) // Before enter tree + { + GD.PrintErr("AddTo does not support to use before enter tree."); + } + + disposable.Dispose(); + } + + node.TreeExited += () => disposable.Dispose(); + }).CallDeferred(); + } }