Browse Source

Tree paints before decompile; AssemblyTreeNode observes load

Two ordering fixes for startup so the user sees the assembly tree before any
decompiled content shows up in the document area, and so the lazy-load story
actually holds.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
a9b6b0d318
  1. 25
      ICSharpCode.ILSpyX/LoadedAssembly.cs
  2. 15
      ILSpy/AssemblyTree/AssemblyListPane.axaml.cs
  3. 32
      ILSpy/AssemblyTree/AssemblyTreeModel.cs
  4. 13
      ILSpy/TextView/DecompilerTabPageModel.cs
  5. 36
      ILSpy/TreeNodes/AssemblyTreeNode.cs

25
ICSharpCode.ILSpyX/LoadedAssembly.cs

@ -65,6 +65,20 @@ namespace ICSharpCode.ILSpyX @@ -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<LoadResult> loadingTask => lazyLoadingTask.Value;
/// <summary>
/// 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
/// <see cref="GetLoadResultAsync"/>, which would trigger the load.
///
/// If the load has already finished by the time you subscribe, the event will
/// not fire retroactively — check <see cref="IsLoaded"/> after subscribing and
/// invoke your handler manually if it returns true.
/// </summary>
public event Action? Loaded;
void RaiseLoaded() => Loaded?.Invoke();
readonly AssemblyList assemblyList;
readonly string fileName;
readonly string shortName;
@ -94,9 +108,18 @@ namespace ICSharpCode.ILSpyX @@ -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<LoadResult>>(
() => 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);
}

15
ILSpy/AssemblyTree/AssemblyListPane.axaml.cs

@ -50,7 +50,20 @@ namespace ILSpy.AssemblyTree @@ -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

32
ILSpy/AssemblyTree/AssemblyTreeModel.cs

@ -145,6 +145,27 @@ namespace ILSpy.AssemblyTree @@ -145,6 +145,27 @@ namespace ILSpy.AssemblyTree
NotifyTextChanged(child);
}
readonly TaskCompletionSource<bool> treeReadyTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// Completes when the assembly-tree view (<c>AssemblyListPane</c>) has fired its
/// <c>Loaded</c> event for the first time. <c>RestoreSelectedPathAsync</c> awaits
/// this before assigning <see cref="SelectedItem"/> 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.
/// </summary>
public Task TreeReady => treeReadyTcs.Task;
/// <summary>
/// Called by <c>AssemblyListPane</c> from its <c>Loaded</c> handler to resolve
/// <see cref="TreeReady"/>. Idempotent — only the first call wins.
/// </summary>
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 @@ -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;
}

13
ILSpy/TextView/DecompilerTabPageModel.cs

@ -54,6 +54,19 @@ namespace ILSpy.TextView @@ -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");
}
}
/// <summary>
/// File extension driving syntax highlighting (e.g. ".cs"). Updated alongside <see cref="Text"/>.
/// </summary>

36
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -62,22 +62,30 @@ namespace ILSpy.TreeNodes @@ -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 @@ -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

Loading…
Cancel
Save