From 71e2be6147d4657037a16b855a93d3cbb708d008 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 15 May 2026 18:54:38 +0200 Subject: [PATCH] Port the three remaining assembly-list-change subscribers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) post-refresh-redecompile: AssemblyTreeModel.RefreshInternalAsync now calls DockWorkspace.ForceRefreshActiveTab() after SelectNode. F5 on the same assembly list doesn't rebuild the tree → FindNodeByPath returns the same tree-node reference → SelectedItem setter early-outs → ShowSelectedNode's lastShownNodes dedup short-circuits, leaving stale decompiled text. The force path resets lastShownNodes and re-runs the decompile pipeline. Mirrors WPF's RefreshDecompiledView() call. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy/AssemblyTree/AssemblyTreeModel.cs | 23 ++++++++ ILSpy/Docking/DockWorkspace.cs | 70 +++++++++++++++++++++++++ ILSpy/NavigationHistory.cs | 16 ++++++ 3 files changed, 109 insertions(+) diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 3731f9b2d..b799f62cc 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -745,6 +745,22 @@ namespace ILSpy.AssemblyTree /// void OnActiveAssemblyListCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + // Prune navigation-history entries that pointed at tree nodes inside removed + // assemblies BEFORE re-publishing — Back/Forward consumers (the toolbar + // commands + dropdowns) re-evaluate their CanExecute when the bus fires, so + // they must see the post-prune state. Mirrors WPF's history.RemoveAll(...) + // inside assemblyList_CollectionChanged. + if (e.OldItems is { Count: > 0 } oldItems) + { + var removed = new HashSet(oldItems.OfType()); + if (removed.Count > 0) + { + TryGetExport()?.PruneHistory(node => + node.AncestorsAndSelf() + .OfType() + .Any(a => removed.Contains(a.LoadedAssembly))); + } + } Util.MessageBus.Send(this, new Util.CurrentAssemblyListChangedEventArgs(e)); } @@ -799,6 +815,13 @@ namespace ILSpy.AssemblyTree } SelectNode(FindNodeByPath(path, returnBestMatch: true)); + + // Defensive re-decompile: F5 on the same assembly list doesn't rebuild the + // tree, so FindNodeByPath returns the same tree-node reference, the + // SelectedItem setter early-outs, and DockWorkspace.ShowSelectedNode's + // dedup short-circuits — leaving stale decompiled text. Force a fresh + // render. Mirrors WPF's RefreshDecompiledView() call. + TryGetExport()?.ForceRefreshActiveTab(); } } } diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 6aa5dbe99..19c22cc1b 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -84,6 +84,21 @@ namespace ILSpy.Docking public IReadOnlyList BackHistory => history.BackEntries; public IReadOnlyList ForwardHistory => history.ForwardEntries; + /// + /// Prunes navigation-history entries whose tree-node ancestor matches + /// . Called from the assembly-tree model + /// when the active assembly list emits a CollectionChanged with removals: + /// every history entry pointing at a node that lived in one of the removed + /// assemblies is dropped so Back/Forward can't surface a detached node. + /// Static-page entries are left alone (they don't reference removed assemblies). + /// + public void PruneHistory(Predicate predicateOnNode) + { + ArgumentNullException.ThrowIfNull(predicateOnNode); + history.RemoveAll(entry => + entry is TreeNodeEntry t && predicateOnNode(t.Node)); + } + public IRootDock Layout { get; } public IReadOnlyList ToolPaneMenuItems { get; } @@ -126,7 +141,46 @@ namespace ILSpy.Docking factory.DockableClosed += OnDocumentMembershipChanged; factory.DockableClosing += OnDockableClosing; factory.ActiveDockableChanged += OnActiveDockableChanged; + // Close orphaned carve-out tabs when their assembly is removed. The persistent + // MainTab slot is left alone — its content will swap to whatever the user selects + // next via the assembly tree. Mirrors WPF's DockWorkspace.CurrentAssemblyList_Changed. + ILSpy.Util.MessageBus.Subscribers + += OnAssemblyListChanged; ILSpy.AppEnv.StartupLog.Mark("DockWorkspace ctor exited"); + } + + void OnAssemblyListChanged(object? sender, ILSpy.Util.CurrentAssemblyListChangedEventArgs e) + { + var inner = e.Inner; + if (inner.OldItems is not { Count: > 0 } oldItems) + return; + var removed = new HashSet( + oldItems.OfType()); + if (removed.Count == 0) + return; + if (factory.Documents?.VisibleDockables is not { } visible) + return; + + foreach (var dockable in visible.OfType().ToArray()) + { + // Skip the persistent MainTab — its content swaps with tree selection. Also + // skip static content (About page etc.) which doesn't reference assemblies. + if (ReferenceEquals(dockable, factory.MainTab)) + continue; + if (dockable.Content is not TextView.DecompilerTabPageModel tab || tab.IsStaticContent) + continue; + var nodes = tab.CurrentNodes; + if (nodes.Count == 0) + continue; + // "Some node in the tab still lives in a non-removed assembly" → keep. + // "Every node points at a removed assembly" → close. + bool anyAlive = nodes.Any(n => + n.AncestorsAndSelf() + .OfType() + .LastOrDefault() is { } owner && !removed.Contains(owner.LoadedAssembly)); + 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 @@ -384,6 +438,22 @@ namespace ILSpy.Docking ILSpyTreeNode[]? lastShownNodes; + /// + /// Force a re-decompile of the active tree selection even if it equals the last + /// rendered selection. Called by at + /// the end of RefreshInternalAsync: when F5 reloads the same assembly list, + /// the tree isn't rebuilt and the SelectedItem reference is preserved, so the + /// normal selection-change cascade would no-op and the editor would keep stale + /// decompiled text. Resetting lastShownNodes defeats the + /// dedup short-circuit inside . Mirrors WPF's + /// RefreshDecompiledView() call. + /// + public void ForceRefreshActiveTab() + { + lastShownNodes = null; + ShowSelectedNode(); + } + void ShowSelectedNode() { var nodes = assemblyTreeModel.SelectedItems.OfType().ToArray(); diff --git a/ILSpy/NavigationHistory.cs b/ILSpy/NavigationHistory.cs index a59d85269..d7db288c6 100644 --- a/ILSpy/NavigationHistory.cs +++ b/ILSpy/NavigationHistory.cs @@ -98,6 +98,22 @@ namespace ILSpy.Navigation current = null; } + /// + /// Removes every entry matched by from both stacks + /// AND from . Used by the assembly-list-changed handler to + /// drop history entries pointing at tree nodes whose containing assembly was + /// just unloaded — without this, Back/Forward could surface a stale entry whose + /// Node reference is now detached from the live tree. + /// + public void RemoveAll(Predicate predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + back.RemoveAll(predicate); + forward.RemoveAll(predicate); + if (current != null && predicate(current)) + current = null; + } + /// Records a new history entry. Discards the forward stack. public void Record(T entry) {