diff --git a/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs
new file mode 100644
index 000000000..03583ae97
--- /dev/null
+++ b/ILSpy.Tests/AssemblyList/SwitchListAndDeleteAllTests.cs
@@ -0,0 +1,130 @@
+// Copyright (c) 2026 Siegfried Pammer
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless;
+using Avalonia.Headless.NUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.AssemblyTree;
+using ICSharpCode.ILSpy.TreeNodes;
+using ICSharpCode.ILSpyX;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Switching to another assembly list replaces the whole tree, and deleting every assembly
+/// empties the one that is showing. Both leave the selection and the navigation history
+/// pointing at nodes that are no longer anywhere in the tree.
+///
+[TestFixture]
+public class SwitchListAndDeleteAllTests
+{
+ static async Task GiveTheHistorySomethingToHoldAsync(ViewModels.MainWindowViewModel vm)
+ {
+ var typeNode = vm.AssemblyTreeModel.FindNode(
+ "System.Linq", "System.Linq", "System.Linq.Enumerable");
+ typeNode.IsExpanded = true;
+ var methods = typeNode.Children.OfType().ToList();
+ vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == "AsEnumerable"));
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ // NavigationHistory collapses selections inside 0.5s into one entry.
+ await Task.Delay(600);
+ vm.AssemblyTreeModel.SelectNode(methods.First(m => m.MethodDefinition.Name == "Empty"));
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ vm.DockWorkspace.BackHistory.Should().NotBeEmpty("the test needs history to lose");
+ }
+
+ [AvaloniaTest]
+ public async Task Switching_To_Another_Assembly_List_Drops_The_Selection_And_History()
+ {
+ // The whole tree is replaced, so every history entry and every selected node belongs to
+ // a tree that is no longer on screen.
+
+ // Arrange - boot, build up history in the default list.
+ var (_, vm) = await TestHarness.BootAsync(3);
+ await GiveTheHistorySomethingToHoldAsync(vm);
+
+ // Act - create another list and switch to it, as the assembly-list dropdown does.
+ var listManager = AppComposition.Current.GetExport().AssemblyListManager;
+ listManager.CreateList("test-list");
+ vm.AssemblyTreeModel.ActiveListName = "test-list";
+ await Waiters.WaitForIdleAsync();
+
+ // Assert - nothing from the previous list is still held on to.
+ vm.AssemblyTreeModel.SelectedItems.Should().BeEmpty("the nodes belong to the previous tree");
+ vm.DockWorkspace.BackHistory.Should().BeEmpty("every entry points into the previous tree");
+ vm.AssemblyTreeModel.Root!.Children.Should().BeEmpty("a freshly created list holds no assemblies");
+ var tab = vm.DockWorkspace.ActiveDecompilerTab;
+ tab.Should().NotBeNull();
+ tab!.Text.Should().BeEmpty("the member it showed belongs to the previous list");
+ tab.Title.Should().Be("(unnamed)", "a tab that shows nothing must not still name what it showed");
+ }
+
+ [AvaloniaTest]
+ public async Task Ctrl_A_Then_Delete_Drops_The_Selection_History_And_Tab()
+ {
+ // The real gestures: SharpTreeView handles Ctrl+A (SelectAll) and Delete
+ // (DeleteSelection) itself, so driving the model's SelectNodes/DeleteCore instead would
+ // skip whatever the view contributes to clearing up.
+
+ // Arrange - boot, decompile something so there is a tab and history to lose, then give
+ // the tree keyboard focus.
+ var (window, vm) = await TestHarness.BootAsync(3);
+ await GiveTheHistorySomethingToHoldAsync(vm);
+ // Collapse again: Ctrl+A selects every visible row, and the tree is normally collapsed
+ // to its assemblies when a user reaches for it.
+ foreach (var node in vm.AssemblyTreeModel.Root!.Children)
+ node.IsExpanded = false;
+ await Waiters.WaitForIdleAsync();
+ var pane = await window.WaitForComponent();
+ var tree = await pane.WaitForComponent();
+ tree.Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ // Act - Ctrl+A, then Delete.
+ int selectedBefore = vm.AssemblyTreeModel.SelectedItems.Count;
+ HeadlessWindowExtensions.KeyPress(window, Key.A, RawInputModifiers.Control, PhysicalKey.A, null);
+ Waiters.PumpUI();
+ vm.AssemblyTreeModel.SelectedItems.Count.Should().BeGreaterThan(selectedBefore,
+ "Ctrl+A must reach the tree and select more than the one row that was selected");
+ vm.DockWorkspace.BackHistory.Should().NotBeEmpty("the history has to survive up to the delete");
+ HeadlessWindowExtensions.KeyPress(window, Key.Delete, RawInputModifiers.None, PhysicalKey.Delete, null);
+ Waiters.PumpUI();
+ await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Length == 0,
+ timeout: TimeSpan.FromSeconds(20));
+ await Waiters.WaitForIdleAsync();
+
+ // Assert - nothing that described the deleted assemblies is left behind.
+ vm.AssemblyTreeModel.SelectedItems.Should().BeEmpty("the selected rows were deleted");
+ vm.DockWorkspace.BackHistory.Should().BeEmpty("every entry pointed into a deleted assembly");
+ var tab = vm.DockWorkspace.ActiveDecompilerTab;
+ tab.Should().NotBeNull();
+ tab!.Text.Should().BeEmpty("the member it showed is gone");
+ tab.Title.Should().Be("(unnamed)", "a tab that shows nothing must not still name what it showed");
+ }
+}
diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs
index 49dc66f6f..a57d7f07f 100644
--- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs
+++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs
@@ -29,6 +29,8 @@ using System.Runtime.Serialization;
using System.Threading;
using System.Threading.Tasks;
+using Avalonia.Threading;
+
using CommunityToolkit.Mvvm.ComponentModel;
using ICSharpCode.Decompiler;
@@ -527,7 +529,17 @@ namespace ICSharpCode.ILSpy.AssemblyTree
// that subscribe to CurrentAssemblyListChangedEventArgs see add/remove events
// from the live list.
if (AssemblyList is { } previous)
+ {
previous.CollectionChanged -= OnActiveAssemblyListCollectionChanged;
+ // Everything below builds a new tree, so the selection, the open tabs and the
+ // navigation history all describe a list that is about to stop being shown.
+ // Nothing is removed from that list - it is the list itself that goes away - so
+ // no collection event announces it, and the panes are told with the same Reset
+ // that clearing a list raises, which is the one path that discards all of it.
+ SelectedItems.Clear();
+ Util.MessageBus.Send(this, new Util.CurrentAssemblyListChangedEventArgs(
+ new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)));
+ }
AssemblyList = list;
list.CollectionChanged += OnActiveAssemblyListCollectionChanged;
if (list.GetAssemblies().Length == 0 && list.ListName == AssemblyListManager.DefaultListName)
@@ -1027,11 +1039,35 @@ namespace ICSharpCode.ILSpy.AssemblyTree
}
Util.MessageBus.Send(this, new Util.CurrentAssemblyListChangedEventArgs(e));
+ // The removed assemblies took the selected node with them. Hand the selection to the
+ // nearest survivor so the tree does not come back empty-handed while it still has
+ // something to show; the tree view does this for its own Delete gesture, but a
+ // removal from anywhere else (the context menu, a command, a reload) would not.
+ // With nothing left there is nothing to select, and the panes stay empty.
+ if (e.OldItems is { Count: > 0 })
+ SelectSurvivorAfterRemoval(e.OldStartingIndex);
+
// List-dependent menu commands (Clear assembly list, Remove assemblies with load errors)
// re-evaluate CanExecute now that the list gained or lost entries.
Commands.CommandManager.InvalidateRequerySuggested();
}
+ ///
+ /// Puts the selection back on the row nearest to where the removed ones were, once the
+ /// tree has caught up with the removal. Does nothing while something is still selected -
+ /// only some of the removed assemblies held the selection, or none did.
+ ///
+ void SelectSurvivorAfterRemoval(int removedIndex)
+ {
+ Dispatcher.UIThread.Post(() => {
+ if (SelectedItems.Count > 0)
+ return;
+ if (Root is not { } root || root.Children.Count == 0)
+ return;
+ SelectNode(root.Children[Math.Clamp(removedIndex, 0, root.Children.Count - 1)]);
+ }, DispatcherPriority.Background);
+ }
+
// Coalesces burst F5 / programmatic Refresh() calls into a single async pipeline.
// Without the gate, two Refresh() in quick succession would run two parallel
// ShowAssemblyList + GetMetadataFileAsync cycles, doubling the work and producing
diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs
index 269f5e5c2..382f15658 100644
--- a/ILSpy/TextView/DecompilerTabPageModel.cs
+++ b/ILSpy/TextView/DecompilerTabPageModel.cs
@@ -346,6 +346,14 @@ namespace ICSharpCode.ILSpy.TextView
n.PropertyChanged -= OnCurrentNodePropertyChanged;
currentNodes = value.ToArray();
cachedBaseTitle = ComposeBaseTitle();
+ if (currentNodes.Count == 0)
+ {
+ // Emptying the tab has to take the title with it. Nothing below assigns one
+ // for an empty tab - StartDecompile has nothing to decompile and returns -
+ // so the tab would keep naming the member it no longer shows. ComposeBaseTitle
+ // already names the empty case.
+ Title = cachedBaseTitle;
+ }
foreach (var n in currentNodes)
n.PropertyChanged += OnCurrentNodePropertyChanged;
// Let host chrome (the omnibar breadcrumb) react to the tab re-targeting a node.