Browse Source

Prune navigation history when assemblies are removed

Ports the WPF assemblyList_CollectionChanged history-prune from
ILSpy/AssemblyTree/AssemblyTreeModel.cs to the Avalonia
DockWorkspace.OnAssemblyListChanged handler. NavigationHistory<T>
already exposed the RemoveAll(Predicate<T>) primitive; the caller
wiring was the missing piece. Without it, a tree-row click after
removing an assembly walked through NavigationEntry.DisplayText
on a stale TreeNodeEntry, which hit MemberReferenceTreeNode.Signature
-> Language.EntityToString -> ILAmbience.ConvertSymbol and NRE'd on
a now-null ParentModule.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
0b8ae0bce7
  1. 16
      ICSharpCode.Decompiler/IL/ILAmbience.cs
  2. 41
      ILSpy/Docking/DockWorkspace.cs

16
ICSharpCode.Decompiler/IL/ILAmbience.cs

@ -53,11 +53,23 @@ namespace ICSharpCode.Decompiler.IL @@ -53,11 +53,23 @@ namespace ICSharpCode.Decompiler.IL
void ConvertSymbol(StringWriter writer, ISymbol symbol)
{
var metadata = (symbol as IEntity)?.ParentModule!.MetadataFile?.Metadata;
var token = (symbol as IEntity)?.MetadataToken ?? default;
var entity = symbol as IEntity;
var metadata = entity?.ParentModule?.MetadataFile?.Metadata;
var token = entity?.MetadataToken ?? default;
var output = new PlainTextOutput(writer);
// When the symbol's owning module has been torn down (stale entity pinned in
// navigation history after the assembly was unloaded/reloaded), the rest of
// the switch can't read its metadata reader. Fall back to the symbol's name
// so callers still get sensible display text — the NRE would otherwise
// surface up through every consumer that formats a stale history entry.
if (metadata == null)
{
writer.Write(symbol?.Name ?? string.Empty);
return;
}
switch (symbol)
{
case IField f:

41
ILSpy/Docking/DockWorkspace.cs

@ -183,12 +183,24 @@ namespace ILSpy.Docking @@ -183,12 +183,24 @@ namespace ILSpy.Docking
void OnAssemblyListChanged(object? sender, ILSpy.Util.CurrentAssemblyListChangedEventArgs e)
{
var inner = e.Inner;
// On Reset (assembly list wholesale-cleared), drop ALL history — every entry is
// stale by definition. Mirrors WPF's assemblyList_CollectionChanged.
if (inner.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
{
PruneHistoryAfterAssemblyListChange(removed: null);
return;
}
if (inner.OldItems is not { Count: > 0 } oldItems)
return;
var removed = new HashSet<ICSharpCode.ILSpyX.LoadedAssembly>(
oldItems.OfType<ICSharpCode.ILSpyX.LoadedAssembly>());
if (removed.Count == 0)
return;
PruneHistoryAfterAssemblyListChange(removed);
if (factory.Documents?.VisibleDockables is not { } visible)
return;
@ -212,14 +224,29 @@ namespace ILSpy.Docking @@ -212,14 +224,29 @@ namespace ILSpy.Docking
if (!anyAlive)
factory.CloseDockable(dockable);
}
}
// TODO: layout persistence (load on startup, save on exit). DockSerializer.SystemTextJson
// trips System.Text.Json's MaxDepth=64 limit on our layout because Dock's JsonConverterList<T>
// calls JsonSerializer.Serialize per list element, which doesn't preserve the writer's
// reference-tracking state between calls — so Owner/Factory back-refs aren't deduplicated as
// $ref. Options when revisiting: try Dock.Serializer.Newtonsoft (better cycle handling),
// or build a custom JsonSerializerOptions with MaxDepth raised + replicate Dock's internal
// polymorphic resolver.
// Drops history entries whose tree node lives inside a now-removed assembly. With
// <paramref name="removed"/> == null, drops everything (used for the Reset action).
// Without this, Back/Forward would surface entries whose Node reference has been
// detached from the live tree, and formatting their DisplayText routes through
// language-specific formatters that NRE on a stale IEntity.ParentModule.
void PruneHistoryAfterAssemblyListChange(HashSet<ICSharpCode.ILSpyX.LoadedAssembly>? removed)
{
if (removed == null)
{
history.RemoveAll(_ => true);
}
else
{
history.RemoveAll(entry =>
entry is Navigation.TreeNodeEntry t
&& t.Node.AncestorsAndSelf()
.OfType<TreeNodes.AssemblyTreeNode>()
.Any(a => removed.Contains(a.LoadedAssembly)));
}
NavigateBackCommand.NotifyCanExecuteChanged();
NavigateForwardCommand.NotifyCanExecuteChanged();
}
void OnDocumentMembershipChanged(object? sender, EventArgs e) => UpdateLastDocumentCanClose();

Loading…
Cancel
Save