diff --git a/ICSharpCode.ILSpyX/LoadedAssembly.cs b/ICSharpCode.ILSpyX/LoadedAssembly.cs index ef60ceeb0..6cf25e2cf 100644 --- a/ICSharpCode.ILSpyX/LoadedAssembly.cs +++ b/ICSharpCode.ILSpyX/LoadedAssembly.cs @@ -65,6 +65,20 @@ namespace ICSharpCode.ILSpyX // demand (any caller that awaits or reads .Result). Status checks below use // IsValueCreated so they don't accidentally trigger the load just by polling. Task loadingTask => lazyLoadingTask.Value; + + /// + /// Fires once when the lazy load task transitions to a completed state (success + /// or failure). Subscribers use it to refresh derived state (tree-node icons, + /// cached metadata pointers) without themselves calling + /// , which would trigger the load. + /// + /// If the load has already finished by the time you subscribe, the event will + /// not fire retroactively — check after subscribing and + /// invoke your handler manually if it returns true. + /// + public event Action? Loaded; + + void RaiseLoaded() => Loaded?.Invoke(); readonly AssemblyList assemblyList; readonly string fileName; readonly string shortName; @@ -94,9 +108,18 @@ namespace ICSharpCode.ILSpyX // callers (e.g. AssemblyListTreeNode) construct LoadedAssembly entries en masse // without flooding the thread pool with metadata-loading tasks; the active // assembly's path-restore awaits first and gets a clean run. + // + // Continuation hooks the Loaded event so subscribers can refresh their display + // (icon / tooltip / lazy children) without themselves triggering the load — they + // observe completion rather than start it. var localStream = stream; this.lazyLoadingTask = new Lazy>( - () => Task.Run(() => LoadAsync(localStream)), + () => { + var task = Task.Run(() => LoadAsync(localStream)); + task.ContinueWith(static (_, state) => ((LoadedAssembly)state!).RaiseLoaded(), + this, TaskScheduler.Default); + return task; + }, LazyThreadSafetyMode.ExecutionAndPublication); // requires that this.fileName is set this.shortName = Path.GetFileNameWithoutExtension(fileName); } diff --git a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs index 72ab82cca..e0e9fb0ed 100644 --- a/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs +++ b/ILSpy/AssemblyTree/AssemblyListPane.axaml.cs @@ -50,7 +50,20 @@ namespace ILSpy.AssemblyTree AppEnv.StartupLog.Mark("AssemblyListPane ctor entered"); InitializeComponent(); AttachedToVisualTree += (_, _) => AppEnv.StartupLog.Mark("AssemblyListPane attached to visual tree"); - Loaded += (_, _) => AppEnv.StartupLog.Mark("AssemblyListPane Loaded"); + Loaded += (_, _) => { + AppEnv.StartupLog.Mark("AssemblyListPane Loaded"); + if (DataContext is AssemblyTreeModel m) + m.MarkTreeReady(); + }; + // One-shot mark on the first DataGridRow being prepared — that's the + // observable "tree has rows on screen" moment we compare against decompiler + // output appearing. + void OnFirstRowLoaded(object? sender, DataGridRowEventArgs args) + { + AppEnv.StartupLog.Mark("Assembly tree DataGrid: first row realised"); + TreeGrid.LoadingRow -= OnFirstRowLoaded; + } + TreeGrid.LoadingRow += OnFirstRowLoaded; TreeGrid.DoubleTapped += OnTreeGridDoubleTapped; TreeGrid.KeyDown += OnTreeGridKeyDown; // Bubble + handledEventsToo: ProDataGrid's row-level pointer handlers mark diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 55f9c2a9a..a01a1e180 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -145,6 +145,27 @@ namespace ILSpy.AssemblyTree NotifyTextChanged(child); } + readonly TaskCompletionSource treeReadyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Completes when the assembly-tree view (AssemblyListPane) has fired its + /// Loaded event for the first time. RestoreSelectedPathAsync awaits + /// this before assigning so the saved-selection path + /// (which kicks off a decompilation through the dock workspace) doesn't paint + /// the document area before the tree itself is on screen. + /// + public Task TreeReady => treeReadyTcs.Task; + + /// + /// Called by AssemblyListPane from its Loaded handler to resolve + /// . Idempotent — only the first call wins. + /// + internal void MarkTreeReady() + { + if (treeReadyTcs.TrySetResult(true)) + AppEnv.StartupLog.Mark("AssemblyTreeModel.TreeReady completed"); + } + public void Initialize() { using var _ = AppEnv.StartupLog.Phase("AssemblyTreeModel.Initialize body"); @@ -214,6 +235,17 @@ namespace ILSpy.AssemblyTree node.EnsureLazyChildren(); node = node.Children.FirstOrDefault(c => c.ToString() == element); } + // Wait for the tree view to be Loaded before assigning SelectedItem. Without + // this, the SelectedItem assignment runs ShowSelectedNode → CurrentNodes → + // async decompilation, and the user can see the decompiled output appear + // BEFORE the assembly tree has rendered — a confusing reverse order. + // The 5-second timeout is a safety net for environments where the pane + // never loads (headless tests, design-time previews). + using (AppEnv.StartupLog.Phase("await TreeReady before SelectedItem assignment")) + { + await Task.WhenAny(TreeReady, Task.Delay(TimeSpan.FromSeconds(5))) + .ConfigureAwait(true); + } if (node != null && node != Root && ReferenceEquals(SelectedItem, initialSelection)) SelectedItem = node; } diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index ee8602464..c6b13002c 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -54,6 +54,19 @@ namespace ILSpy.TextView [ObservableProperty] private string text = string.Empty; + // Diagnostics: stamp the first non-empty Text assignment so we can correlate when + // decompiled output becomes visible against the tree-render path. The check is + // idempotent — only the first assignment for the lifetime of the process gets marked. + static int firstTextMarked; + partial void OnTextChanged(string? oldValue, string newValue) + { + if (!string.IsNullOrEmpty(newValue) + && System.Threading.Interlocked.Exchange(ref firstTextMarked, 1) == 0) + { + ILSpy.AppEnv.StartupLog.Mark("DecompilerTabPageModel: first non-empty Text set"); + } + } + /// /// File extension driving syntax highlighting (e.g. ".cs"). Updated alongside . /// diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index 5afd054b3..4cd792d5c 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -62,22 +62,30 @@ namespace ILSpy.TreeNodes this.assembly = assembly; this.PackageEntry = packageEntry; LazyLoading = true; - _ = InitAsync(); + // Observe the load OUTCOME without triggering it — the cooldown sweep and + // explicit user actions (path-restore, expand) are what start the load. + // If we called GetLoadResultAsync here we'd flatten the lazy strategy and + // every assembly in the list would load the moment its tree node is built. + assembly.Loaded += OnAssemblyLoaded; + if (assembly.IsLoaded) + OnAssemblyLoaded(); // already finished before we subscribed — catch up } - // Assemblies nested in NuGet packages can't be unloaded individually — the parent - // package entry owns them. - public override bool CanDelete() => PackageEntry == null; - - public override void Delete() => DeleteCore(); - - public override void DeleteCore() => assembly.AssemblyList.Unload(assembly); + void OnAssemblyLoaded() + { + // The Loaded event fires from a thread-pool ContinueWith. Marshal to the UI + // thread before mutating tree-node state and raising change notifications; + // SharpTreeNode's RaisePropertyChanged is not thread-safe. + global::Avalonia.Threading.Dispatcher.UIThread.Post(InitFromLoadResult, + global::Avalonia.Threading.DispatcherPriority.Background); + } - async Task InitAsync() + void InitFromLoadResult() { try { - var loadResult = await assembly.GetLoadResultAsync(); + // GetLoadResultAsync is a no-op now — the load is already complete. + var loadResult = assembly.GetLoadResultAsync().GetAwaiter().GetResult(); cachedModule = loadResult.MetadataFile; if (cachedModule == null && loadResult.Package == null) { @@ -96,6 +104,14 @@ namespace ILSpy.TreeNodes RaisePropertyChanged(nameof(ShowExpander)); } + // Assemblies nested in NuGet packages can't be unloaded individually — the parent + // package entry owns them. + public override bool CanDelete() => PackageEntry == null; + + public override void Delete() => DeleteCore(); + + public override void DeleteCore() => assembly.AssemblyList.Unload(assembly); + public override object Text => assembly.Text; // ToString is the stable identity used by SessionSettings.ActiveTreeViewPath — must not