diff --git a/ICSharpCode.ILSpyX/AssemblyListManager.cs b/ICSharpCode.ILSpyX/AssemblyListManager.cs index 9e635eec0..e98657b68 100644 --- a/ICSharpCode.ILSpyX/AssemblyListManager.cs +++ b/ICSharpCode.ILSpyX/AssemblyListManager.cs @@ -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) + /// + /// Opens every managed framework assembly in into + /// , 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. + /// + 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; } } } diff --git a/ILSpy.Tests/AssemblyList/DefaultAssemblyListTests.cs b/ILSpy.Tests/AssemblyList/DefaultAssemblyListTests.cs new file mode 100644 index 000000000..4126b0973 --- /dev/null +++ b/ILSpy.Tests/AssemblyList/DefaultAssemblyListTests.cs @@ -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().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")); + } +} diff --git a/ILSpy.Tests/AssemblyTree/StartupAboutWelcomeTests.cs b/ILSpy.Tests/AssemblyTree/StartupAboutWelcomeTests.cs new file mode 100644 index 000000000..2dbbd0fa4 --- /dev/null +++ b/ILSpy.Tests/AssemblyTree/StartupAboutWelcomeTests.cs @@ -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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var dock = AppComposition.Current.GetExport(); + + // 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"); + } +} diff --git a/ILSpy.Tests/TestApp.axaml.cs b/ILSpy.Tests/TestApp.axaml.cs index 859bfd8ac..89e1447aa 100644 --- a/ILSpy.Tests/TestApp.axaml.cs +++ b/ILSpy.Tests/TestApp.axaml.cs @@ -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 diff --git a/ILSpy/App.axaml.cs b/ILSpy/App.axaml.cs index 64edee8e9..0ae451cff 100644 --- a/ILSpy/App.axaml.cs +++ b/ILSpy/App.axaml.cs @@ -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); diff --git a/ILSpy/AssemblyTree/AssemblyTreeModel.cs b/ILSpy/AssemblyTree/AssemblyTreeModel.cs index 853735d98..5008c54e7 100644 --- a/ILSpy/AssemblyTree/AssemblyTreeModel.cs +++ b/ILSpy/AssemblyTree/AssemblyTreeModel.cs @@ -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(); + 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) diff --git a/ILSpy/Commands/AboutCommand.cs b/ILSpy/Commands/AboutCommand.cs index b95c21d8f..ab66ca215 100644 --- a/ILSpy/Commands/AboutCommand.cs +++ b/ILSpy/Commands/AboutCommand.cs @@ -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")); + } + + /// + /// 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 when ILSpy + /// launches with no node selected and nothing to restore. + /// + 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(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)); } diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 9a18d5420..e7e971f19 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -1091,6 +1091,32 @@ namespace ILSpy.Docking return tab; } + /// + /// Renders in the reusable MainTab as a transient welcome + /// page (the About page shown at startup when nothing is selected). Unlike Help > + /// 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. + /// + /// + /// 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. + /// + 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); + } + /// /// VS-style "pin tab" gesture. The current factory.MainTab keeps its position, /// content, and active state but flips to pinned