Show About page and seed running framework on first run

Two first-run behaviours that the previous version had and the port had
lost. When a launch has nothing to restore (no saved tree path, no
command-line target), greet the user with the About page in the main
tab instead of an empty "(no selection)" view; the greeting is fired
after the tree is ready and only takes the main tab when it's still
empty and active, so it can't steal activation from a tab the user
opened meanwhile (e.g. Compare...). And seed the default list with the
.NET framework ILSpy itself runs on - every managed assembly in the
running runtime's shared-framework directory. Headless tests opt out of
that seed (re-opening ~150 assemblies per test is ~10x slower) and fall
back to the minimal trio, leaving their expectations unchanged.

Assisted-by: Claude:claude-opus-4-8:Claude Code
This commit is contained in:
Siegfried Pammer
2026-06-08 18:34:55 +02:00
parent 8b26f2c0e9
commit dc1ae9c467
8 changed files with 308 additions and 57 deletions
+27 -21
View File
@@ -267,12 +267,7 @@ namespace ICSharpCode.ILSpyX
AddToListFromGAC("Microsoft.CSharp, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a");
break;
case object _ when path != null:
foreach (var file in Directory.GetFiles(path, "*.dll"))
{
var dllname = Path.GetFileName(file);
if (DoIncludeFile(dllname))
AddToListFromDirectory(file);
}
AddFrameworkAssembliesFromDirectory(list, path);
break;
}
return list;
@@ -284,27 +279,38 @@ namespace ICSharpCode.ILSpyX
if (file != null)
list.OpenAssembly(file);
}
}
void AddToListFromDirectory(string file)
/// <summary>
/// Opens every managed framework assembly in <paramref name="directory"/> into
/// <paramref name="list"/>, applying the same filter the preconfigured runtime lists use
/// (skips native helpers like *_cor3.dll and lower-cased runtime libraries such as
/// coreclr.dll / clrjit.dll). Shared by the "preconfigured runtime list" path and the
/// first-run default list, which seeds itself from the shared-framework directory ILSpy
/// is running on.
/// </summary>
public void AddFrameworkAssembliesFromDirectory(AssemblyList list, string directory)
{
foreach (var file in Directory.GetFiles(directory, "*.dll"))
{
if (File.Exists(file))
if (IsIncludedFrameworkFile(Path.GetFileName(file)))
list.OpenAssembly(file);
}
}
bool DoIncludeFile(string fileName)
{
if (fileName == "Microsoft.DiaSymReader.Native.amd64.dll")
return false;
if (fileName.EndsWith("_cor3.dll", StringComparison.OrdinalIgnoreCase))
return false;
if (char.IsUpper(fileName[0]))
return true;
if (fileName == "netstandard.dll")
return true;
if (fileName == "mscorlib.dll")
return true;
static bool IsIncludedFrameworkFile(string fileName)
{
if (fileName == "Microsoft.DiaSymReader.Native.amd64.dll")
return false;
}
if (fileName.EndsWith("_cor3.dll", StringComparison.OrdinalIgnoreCase))
return false;
if (char.IsUpper(fileName[0]))
return true;
if (fileName == "netstandard.dll")
return true;
if (fileName == "mscorlib.dll")
return true;
return false;
}
}
}
@@ -0,0 +1,69 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// 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.IO;
using System.Linq;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpyX;
using ILSpy;
using ILSpy.AppEnv;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class DefaultAssemblyListTests
{
[AvaloniaTest]
public void Framework_Directory_Seed_Loads_Managed_Assemblies_And_Skips_Native()
{
// The first-run default list seeds itself from the shared-framework directory the
// running runtime lives in, via AssemblyListManager.AddFrameworkAssembliesFromDirectory.
// This exercises that seeding directly (the app wiring just calls it for the running
// runtime's directory): managed framework assemblies load; native runtime libraries are
// filtered out.
var manager = AppComposition.Current.GetExport<SettingsService>().AssemblyListManager;
var list = manager.CreateList("framework-seed-probe-" + Guid.NewGuid().ToString("N"));
var frameworkDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
manager.AddFrameworkAssembliesFromDirectory(list, frameworkDir);
var assemblies = list.GetAssemblies();
var names = assemblies.Select(a => a.ShortName).ToHashSet();
names.Should().Contain("System.Private.CoreLib");
names.Should().Contain("System.Linq");
names.Should().Contain("System.Collections");
assemblies.Length.Should().BeGreaterThan(20,
"the whole shared-framework directory is far larger than a hard-coded handful");
assemblies.Should().OnlyContain(a => Path.GetDirectoryName(a.FileName) == frameworkDir,
"every seeded assembly comes from the framework directory");
// Native runtime libraries must not be loaded as assemblies.
names.Should().NotContain(n => n.Contains("coreclr"));
names.Should().NotContain(n => n.Contains("clrjit"));
}
}
@@ -0,0 +1,66 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// 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.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpy.Properties;
using ILSpy.AppEnv;
using ILSpy.Docking;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class StartupAboutWelcomeTests
{
[AvaloniaTest]
public async Task Startup_With_No_Saved_Selection_Shows_The_About_Page()
{
// On launch with nothing to restore (no saved tree-view path, no command-line target),
// ILSpy should greet the user with the About page in the main tab, rather than an empty
// "(no selection)" view. Mirrors the previous version's startup behaviour.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var dock = AppComposition.Current.GetExport<DockWorkspace>();
// The About page is shown asynchronously after the tree signals it is ready.
await Waiters.WaitForAsync(
() => dock.ActiveDecompilerTab is { } tab && Equals(tab.Title, Resources.About),
description: "the empty-startup main tab should host the About welcome page");
// No tree node ended up selected, and the active page is the About page.
((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeNull(
"nothing should be selected when there is no saved selection to restore");
var active = dock.ActiveDecompilerTab;
active.Should().NotBeNull();
((object?)active!.Title).Should().Be(Resources.About);
active.Text.Should().NotBeNullOrWhiteSpace("the About page must have rendered its content");
}
}
+4
View File
@@ -41,6 +41,10 @@ public class TestApp : ProductionApp
public override void OnFrameworkInitializationCompleted()
{
// Tests boot the app repeatedly; seeding the entire shared framework (~150 assemblies)
// per test is ~10x slower. Opt into the minimal default-list seed instead.
ProductionApp.SeedFullFrameworkDefaultList = false;
var sessionsRoot = Path.Combine(Path.GetTempPath(), "ILSpy.Tests");
// Sweep any leftover session dirs from previous runs first — ProcessExit-time cleanup
// is best-effort and the active session's settings file is sometimes still locked when
+6
View File
@@ -38,6 +38,12 @@ namespace ILSpy
public static CommandLineArguments? CommandLineArguments { get; private set; }
public static CompositionHost? Composition { get; private set; }
// On first run the default assembly list is seeded with the entire shared-framework
// directory the running runtime lives in (~150 assemblies). Headless tests flip this
// off so each test boots with a minimal default instead of re-seeding the whole
// framework per test (which is ~10x slower); see TestApp.
public static bool SeedFullFrameworkDefaultList { get; set; } = true;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
+83 -30
View File
@@ -56,6 +56,9 @@ namespace ILSpy.AssemblyTree
readonly SettingsService settingsService;
readonly LanguageService languageService;
AssemblyListManager? listManager;
// True until the first assembly-list activation completes — gates the startup-only
// About-page greeting so switching lists later never re-triggers it.
bool initialActivation = true;
AssemblyListTreeNode? assemblyListTreeNode;
[ObservableProperty]
@@ -298,6 +301,11 @@ namespace ILSpy.AssemblyTree
settingsService.SessionSettings.ActiveAssemblyList = value;
ShowAssemblyList(value);
// First activation is the startup one; a later switch to a different list isn't.
// Only startup gets the empty-selection About-page greeting.
bool isInitial = initialActivation;
initialActivation = false;
// Restore the previously-selected tree node off the UI critical path. The walk
// crosses an AssemblyTreeNode whose EnsureLazyChildren synchronously blocks on
// GetLoadResultAsync — by going async here and awaiting the load, we let the
@@ -311,36 +319,37 @@ namespace ILSpy.AssemblyTree
if (App.CommandLineArguments?.NavigateTo is { Length: > 0 })
return;
var savedPath = settingsService.SessionSettings.ActiveTreeViewPath;
if (savedPath is { Length: > 0 })
_ = RestoreSelectedPathAsync(savedPath);
_ = RestoreSelectedPathAsync(savedPath, isInitial);
}
async Task RestoreSelectedPathAsync(string[] path)
async Task RestoreSelectedPathAsync(string[]? path, bool isInitial)
{
using var _ = AppEnv.AppLog.Phase($"RestoreSelectedPathAsync ({path.Length} segments)");
using var _ = AppEnv.AppLog.Phase($"RestoreSelectedPathAsync ({path?.Length ?? 0} segments, initial={isInitial})");
try
{
if (Root == null)
return;
// Snapshot — if the user selects something else before the restore completes,
// don't yank their selection out from under them.
var initialSelection = SelectedItem;
SharpTreeNode? node = Root;
foreach (var element in path)
SharpTreeNode? node = null;
if (path is { Length: > 0 } && Root != null)
{
if (node == null)
break;
// Awaiting GetLoadResultAsync keeps EnsureLazyChildren — which itself does
// GetAwaiter().GetResult() on the same task — from blocking the UI thread.
// Once the load completes the .GetAwaiter().GetResult() returns instantly.
if (node is AssemblyTreeNode asm)
node = Root;
foreach (var element in path)
{
using (AppEnv.AppLog.Phase($"await GetLoadResultAsync ({asm.LoadedAssembly.ShortName})"))
await asm.LoadedAssembly.GetLoadResultAsync().ConfigureAwait(true);
if (node == null)
break;
// Awaiting GetLoadResultAsync keeps EnsureLazyChildren — which itself does
// GetAwaiter().GetResult() on the same task — from blocking the UI thread.
// Once the load completes the .GetAwaiter().GetResult() returns instantly.
if (node is AssemblyTreeNode asm)
{
using (AppEnv.AppLog.Phase($"await GetLoadResultAsync ({asm.LoadedAssembly.ShortName})"))
await asm.LoadedAssembly.GetLoadResultAsync().ConfigureAwait(true);
}
using (AppEnv.AppLog.Phase($"EnsureLazyChildren ({node.GetType().Name} \"{element}\")"))
node.EnsureLazyChildren();
node = node.Children.FirstOrDefault(c => c.ToString() == element);
}
using (AppEnv.AppLog.Phase($"EnsureLazyChildren ({node.GetType().Name} \"{element}\")"))
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 →
@@ -353,8 +362,19 @@ namespace ILSpy.AssemblyTree
await Task.WhenAny(TreeReady, Task.Delay(TimeSpan.FromSeconds(5)))
.ConfigureAwait(true);
}
if (node != null && node != Root && ReferenceEquals(SelectedItem, initialSelection))
// Bail if the user selected something else while we were resolving.
if (!ReferenceEquals(SelectedItem, initialSelection))
return;
if (node != null && node != Root)
{
SelectedItem = node;
}
else if (isInitial && SelectedItem == null)
{
// Launched with nothing to restore (no saved path, or it no longer resolves):
// greet the user with the About page in the main tab instead of a blank view.
ShowAboutWelcomePage();
}
}
catch (Exception ex)
{
@@ -362,6 +382,19 @@ namespace ILSpy.AssemblyTree
}
}
// Open the About page as the startup welcome screen. The command is resolved lazily
// through the menu registry (it's an ExportFactory, not instantiated until needed),
// and its instance IS the AboutCommand, so we can drive its welcome path directly.
void ShowAboutWelcomePage()
{
var registry = TryGetExport<MainMenuCommandRegistry>();
var export = registry?.Commands
.FirstOrDefault(c => c.Metadata.Header == nameof(ICSharpCode.ILSpy.Properties.Resources._About))
?.CreateExport();
if (export?.Value is Commands.AboutCommand about)
about.ShowWelcome();
}
void ShowAssemblyList(string name)
{
if (listManager == null)
@@ -388,7 +421,7 @@ namespace ILSpy.AssemblyTree
if (list.GetAssemblies().Length == 0 && list.ListName == AssemblyListManager.DefaultListName)
{
using (AppEnv.AppLog.Phase("LoadInitialAssemblies"))
LoadInitialAssemblies(list);
LoadInitialAssemblies(list, listManager);
}
AppEnv.AppLog.Mark($"AssemblyList contains {list.GetAssemblies().Length} assemblies");
using (AppEnv.AppLog.Phase("new AssemblyListTreeNode"))
@@ -602,18 +635,38 @@ namespace ILSpy.AssemblyTree
.FirstOrDefault(n => n.MethodDefinition.MetadataToken == method.MetadataToken);
}
static void LoadInitialAssemblies(AssemblyList assemblyList)
static void LoadInitialAssemblies(AssemblyList assemblyList, AssemblyListManager? manager)
{
System.Reflection.Assembly[] initialAssemblies = {
typeof(object).Assembly,
typeof(Uri).Assembly,
typeof(System.Linq.Enumerable).Assembly,
};
foreach (var asm in initialAssemblies)
// Headless tests opt out of the full-framework seed (it would re-open ~150 assemblies
// per test); they fall back to the minimal trio the previous version shipped so their
// expectations and runtime stay unchanged.
if (!App.SeedFullFrameworkDefaultList || manager == null)
{
if (!string.IsNullOrEmpty(asm.Location))
assemblyList.OpenAssembly(asm.Location);
System.Reflection.Assembly[] minimal = {
typeof(object).Assembly,
typeof(Uri).Assembly,
typeof(System.Linq.Enumerable).Assembly,
};
foreach (var asm in minimal)
{
if (!string.IsNullOrEmpty(asm.Location))
assemblyList.OpenAssembly(asm.Location);
}
return;
}
// First-run default: seed the .NET framework assemblies ILSpy itself is running on -
// every managed assembly in the shared-framework directory that hosts the running
// runtime (the folder containing System.Private.CoreLib). The manager applies the
// same managed-PE filter the preconfigured runtime lists use, so native runtime
// libraries (coreclr, clrjit, *_cor3.dll, ...) are skipped.
var coreLibLocation = typeof(object).Assembly.Location;
if (string.IsNullOrEmpty(coreLibLocation))
return;
var frameworkDirectory = System.IO.Path.GetDirectoryName(coreLibLocation);
if (string.IsNullOrEmpty(frameworkDirectory))
return;
manager.AddFrameworkAssembliesFromDirectory(assemblyList, frameworkDirectory);
}
public void SelectNode(SharpTreeNode? node)
+27 -6
View File
@@ -57,6 +57,27 @@ namespace ILSpy.Commands
}
public override void Execute(object? parameter)
{
var output = BuildAboutOutput();
var content = CreateTabContent(Resources.About, output, ".txt", isStaticContent: true);
var tab = dockWorkspace.OpenNewTab(content);
dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage"));
}
/// <summary>
/// Renders the About page into the reusable main tab as the startup welcome screen.
/// The content is non-static so the first tree-node selection cleanly reuses the tab
/// and replaces it. Invoked by <see cref="AssemblyTree.AssemblyTreeModel"/> when ILSpy
/// launches with no node selected and nothing to restore.
/// </summary>
public void ShowWelcome()
{
var output = BuildAboutOutput();
var content = CreateTabContent(Resources.About, output, ".txt", isStaticContent: false);
dockWorkspace.ShowWelcomePage(content);
}
AvaloniaEditTextOutput BuildAboutOutput()
{
var output = new AvaloniaEditTextOutput { Title = Resources.About };
output.WriteLine(Resources.ILSpyVersion + DecompilerVersionInfo.FullVersionWithCommitHash);
@@ -81,11 +102,10 @@ namespace ILSpy.Commands
foreach (var (phrase, uri) in Links)
output.AddVisualLineElementGenerator(new ResourceLinkGenerator(phrase, uri));
var (tab, _) = OpenInNewTab(Resources.About, output, ".txt");
dockWorkspace.RecordStaticPage(tab, new Uri("resource:aboutpage"));
return output;
}
(ContentTabPage Tab, DecompilerTabPageModel Content) OpenInNewTab(string title, AvaloniaEditTextOutput output, string syntaxExtension)
DecompilerTabPageModel CreateTabContent(string title, AvaloniaEditTextOutput output, string syntaxExtension, bool isStaticContent)
{
var content = new DecompilerTabPageModel {
Title = title,
@@ -96,13 +116,13 @@ namespace ILSpy.Commands
DefinitionLookup = output.DefinitionLookup,
UIElements = output.UIElements,
Foldings = output.Foldings,
IsStaticContent = true,
IsStaticContent = isStaticContent,
CustomElementGenerators = output.ElementGenerators.Count > 0
? new List<VisualLineElementGenerator>(output.ElementGenerators)
: null,
};
content.OpenUriRequested += OnOpenUri;
return (dockWorkspace.OpenNewTab(content), content);
return content;
}
bool OnOpenUri(Uri uri)
@@ -122,7 +142,8 @@ namespace ILSpy.Commands
using var reader = new StreamReader(stream);
var output = new AvaloniaEditTextOutput { Title = resourceName };
output.Write(reader.ReadToEnd());
var (tab, _) = OpenInNewTab(resourceName, output, ".txt");
var content = CreateTabContent(resourceName, output, ".txt", isStaticContent: true);
var tab = dockWorkspace.OpenNewTab(content);
dockWorkspace.RecordStaticPage(tab, new Uri("resource:" + resourceName));
}
+26
View File
@@ -1091,6 +1091,32 @@ namespace ILSpy.Docking
return tab;
}
/// <summary>
/// Renders <paramref name="content"/> in the reusable MainTab as a transient welcome
/// page (the About page shown at startup when nothing is selected). Unlike Help &gt;
/// About this does not pin a static page: the content is non-static, so the first
/// tree-node selection reuses the MainTab and replaces it, leaving the user with a
/// single tab rather than a stray empty one.
/// </summary>
/// <remarks>
/// The greeting is fired asynchronously at startup (after the tree signals ready), so by
/// the time it runs the user may already have opened a document tab that doesn't move the
/// tree selection (e.g. Compare...). Only greet when the MainTab is still empty AND still
/// the active document; otherwise stealing activation would yank the user off whatever
/// they just opened.
/// </remarks>
public void ShowWelcomePage(TextView.DecompilerTabPageModel content)
{
if (factory.MainTab is not { } main)
return;
if (main.Content != null)
return;
if (factory.Documents is { } docs && !ReferenceEquals(docs.ActiveDockable, main))
return;
main.Content = content;
ActivateMainTabIfNeeded(main);
}
/// <summary>
/// VS-style "pin tab" gesture. The current <c>factory.MainTab</c> keeps its position,
/// content, and active state but flips to pinned