From d6c1a54c02c699cb820d95b47a8b4970507ba928 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 14 Jul 2026 23:11:22 +0200 Subject: [PATCH 1/4] Run every Save Code path behind a cancellable progress overlay File -> Save Code decompiled a whole assembly on a bare Task.Run: no progress bar, no way to cancel, and (for a .csproj) diverging from both normal decompilation and the dedicated Export Project command, which already report progress. Route the assembly-save paths through the shared UI instead: - The .csproj export reuses the Export Project machinery (ProjectExporter in a frozen, determinate-progress tab), so a large assembly reports per-file progress and can be cancelled while the tree stays browsable. - The single-file save runs behind the same RunWithCancellation overlay that normal decompilation uses. Both entry points into the project export compute the tab title in one place, titling it after the assemblies being exported (their tree-node labels, joined the way a multi-node decompile tab is) so the tab reads the same whether reached via Save Code or Export Project. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Commands/SaveCodeProjectExportTests.cs | 121 ++++++++++++++++++ ILSpy/Commands/ProjectExport.cs | 54 +++++++- ILSpy/Commands/SaveCodeHelper.cs | 18 +++ ILSpy/TreeNodes/AssemblyTreeNode.cs | 29 ++++- 4 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs new file mode 100644 index 0000000000..451fe4441e --- /dev/null +++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs @@ -0,0 +1,121 @@ +// 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.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Commands; + +/// +/// Every File -> Save Code path for an assembly runs behind a cancellable progress overlay, never a +/// bare Task.Run: the .csproj export goes through +/// (the Export Project command's frozen determinate-progress tab), and the single-file save goes through +/// (the same overlay normal decompilation +/// uses). These assert both paths write their output and surface a report/breadcrumb in a tab. +/// +[TestFixture] +public class SaveCodeProjectExportTests +{ + [AvaloniaTest] + public async Task Save_As_Project_Runs_Through_The_Export_Project_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var assembly = await vm.OpenFixtureAsync(); + var dock = vm.DockWorkspace; + + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveProj_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + await ProjectExport.ExportSingleAssemblyAsync( + assembly, tempDir, settings, language, dock); + + Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1, + "Save Code -> .csproj must run the whole-project decompiler, exactly like Export Project"); + Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(); + + var exportTab = dock.Documents!.VisibleDockables!.OfType() + .Select(t => t.Content).OfType() + .FirstOrDefault(d => d.Text != null && d.Text.Contains("Project written to")); + exportTab.Should().NotBeNull("the export runs in its own progress tab and shows the project-written report there"); + exportTab!.Title.Should().Be(assembly.Text, + "a single-assembly export tab is titled after that assembly's tree-node label -- the same however it was started (Save Code or Export Project)"); + } + finally + { + try + { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort */ } + } + } + + [AvaloniaTest] + public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var assembly = await vm.OpenFixtureAsync(); + var dock = vm.DockWorkspace; + + var assemblyNode = vm.AssemblyTreeModel.FindNode(assembly.ShortName); + Assert.That(assemblyNode, Is.Not.Null); + // Select the assembly so there is an active decompiler tab for RunWithCancellation to run on. + vm.AssemblyTreeModel.SelectNode(assemblyNode); + await dock.WaitForDecompiledTextAsync(); + + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + + var path = Path.Combine(Path.GetTempPath(), "ILSpySaveSingle_" + System.Guid.NewGuid().ToString("N") + ".cs"); + try + { + await SaveCodeHelper.SaveNodeToFileWithProgressAsync(assemblyNode!, language, path, dock); + + File.Exists(path).Should().BeTrue("the single-file save must write the assembly to disk"); + (await File.ReadAllTextAsync(path)).Should().Contain(FixtureAssembly.TypeName, + "the decompiled assembly's type must be present"); + + dock.Documents!.VisibleDockables!.OfType() + .Any(t => t.Content is DecompilerTabPageModel d && d.Text != null && d.Text.Contains("Decompilation complete")) + .Should().BeTrue("the single-file save runs behind the cancellable overlay and shows a completion breadcrumb"); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } +} diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index dd8d6e060f..9bb892e369 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -25,6 +25,7 @@ using global::Avalonia.Controls.ApplicationLifetimes; +using ICSharpCode.Decompiler; using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; @@ -77,11 +78,58 @@ public static async Task PromptAndExportAsync(IReadOnlyList asse return; var settingsClone = settingsService.DecompilerSettings.Clone(); - // Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it. - await dockWorkspace.RunInNewTabAsync(ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution, async (token, progress) => { + await RunExportAsync(assemblies, solutionMode, options, settingsClone, language, dockWorkspace).ConfigureAwait(true); + } + + /// + /// Exports a single assembly as a decompiled project into , using + /// the current decompiler settings (no export-dialog overrides, no PDB, no strong-name key). This is + /// the File -> Save Code -> .csproj path: it reuses the same + + /// frozen-progress-tab machinery as the Export Project command, so a large assembly reports real + /// progress and can be cancelled, instead of running silently. The tab is titled the same way as the + /// Export Project command (see ) so the same operation reads the same + /// however it was started. + /// + public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string outputDirectory, + DecompilerSettings settings, Language language, DockWorkspace dockWorkspace) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(dockWorkspace); + + // Mirror the live settings into the options so ProjectExporter.ApplyOverrides is a no-op and the + // output matches the plain "Save Code" behaviour exactly, just with progress now surfaced. + var options = new ProjectExportOptions( + OutputDirectory: outputDirectory, + UseSdkStyleProjectFormat: settings.UseSdkStyleProjectFormat, + UseNestedDirectoriesForNamespaces: settings.UseNestedDirectoriesForNamespaces, + RemoveDeadCode: settings.RemoveDeadCode, + RemoveDeadStores: settings.RemoveDeadStores, + UseDebugSymbols: settings.UseDebugSymbols, + StrongNameKeyFile: null, + GeneratePdb: false, + EmbedSourceFilesInPdb: false); + return RunExportAsync(new List { assembly }, solutionMode: false, options, + settings.Clone(), language, dockWorkspace); + } + + // Runs the export behind a dedicated frozen tab (so browsing the tree while it runs can't cancel it) + // and reports the result there, with an Open-folder button on success. Shared by the Export Project + // command and the Save Code -> .csproj path. The tab is titled after the assemblies being exported, + // joining their full tree-node labels the same way DecompilerTabPageModel.ComposeBaseTitle titles a + // multi-node decompile tab -- so a single-assembly export reads as that assembly and a solution + // export as its members, ellipsised on the tab with the full list shown as a tooltip. + static Task RunExportAsync(IReadOnlyList assemblies, bool solutionMode, + ProjectExportOptions options, DecompilerSettings settingsClone, Language language, + DockWorkspace dockWorkspace) + { + var title = assemblies.Count > 0 + ? string.Join(", ", assemblies.Select(a => a.Text)) + : ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution; + return dockWorkspace.RunInNewTabAsync(title, async (token, progress) => { var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, progress, token) .ConfigureAwait(false); - var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution }; + var o = new AvaloniaEditTextOutput { Title = title }; o.Write(result.StatusText); o.WriteLine(); if (result.Success && Directory.Exists(options.OutputDirectory)) diff --git a/ILSpy/Commands/SaveCodeHelper.cs b/ILSpy/Commands/SaveCodeHelper.cs index b96e570249..921467d085 100644 --- a/ILSpy/Commands/SaveCodeHelper.cs +++ b/ILSpy/Commands/SaveCodeHelper.cs @@ -68,6 +68,24 @@ public static async Task SaveNodeAsync(ILSpyTreeNode node, LanguageService langu if (path == null) return; + await SaveNodeToFileWithProgressAsync(node, language, path, dockWorkspace).ConfigureAwait(true); + } + + /// + /// Decompiles into behind the active tab's + /// cancellable progress overlay, then shows a "decompilation complete" breadcrumb (with an + /// Open-folder button) in that tab. Shared by the generic Save Code fallback and the assembly + /// single-file save so both get the same progress/cancel UX. The caller has already settled on + /// ; cancelling here means cancelling the decompilation from the overlay, + /// which leaves the tab's contents as they were. + /// + public static async Task SaveNodeToFileWithProgressAsync(ILSpyTreeNode node, Language language, + string path, DockWorkspace dockWorkspace) + { + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(dockWorkspace); + try { // Run the decompile-to-file behind the tab's cancellable progress overlay, then show a diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index ba94e57c51..942de3377c 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -190,17 +190,40 @@ async Task SaveAsProjectOrSingleFileAsync(Language language) + $"|{language.Name} (*{language.FileExtension})|*{language.FileExtension}" + "|All files (*.*)|*.*"; var defaultName = WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, language.ProjectFileExtension); - var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(false); + var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(true); if (string.IsNullOrEmpty(path)) return; var ext = Path.GetExtension(path); var isProject = string.Equals(ext, language.ProjectFileExtension, StringComparison.OrdinalIgnoreCase); - var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() + var dockWorkspace = AppEnv.AppComposition.TryGetExport(); + + // A whole-assembly .csproj export is the expensive case, so route it through the same + // determinate-progress export path as the Export Project command (a dedicated frozen tab, so + // browsing the tree while it runs can't cancel it). + if (isProject && dockWorkspace != null + && Path.GetDirectoryName(path) is { Length: > 0 } projectDirectory) + { + var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() + ?? new ICSharpCode.Decompiler.DecompilerSettings(); + await Commands.ProjectExport.ExportSingleAssemblyAsync( + assembly, projectDirectory, settings, language, dockWorkspace).ConfigureAwait(true); + return; + } + + // Single-file save: the same cancellable progress overlay normal decompilation uses. + if (dockWorkspace != null) + { + await Commands.SaveCodeHelper.SaveNodeToFileWithProgressAsync(this, language, path, dockWorkspace).ConfigureAwait(true); + return; + } + + // No DockWorkspace (non-interactive host): fall back to a plain, non-cancellable write. + var fallbackSettings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() ?? new ICSharpCode.Decompiler.DecompilerSettings(); await Task.Run(() => { - var options = new DecompilationOptions(settings) { + var options = new DecompilationOptions(fallbackSettings) { FullDecompilation = true, EscapeInvalidIdentifiers = true, }; From 2e9bdd2ae3cc5189172e567a72ebba8964ea3aab Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 15 Jul 2026 21:24:20 +0200 Subject: [PATCH 2/4] Fold the solution export into the shared project-export path Save Code on several assemblies had its own copy of the export flow: its own selection matcher, its own frozen-tab runner, and a hard-coded "Exporting solution" tab title -- so the same operation read differently depending on whether it was started from Save Code or Export Project, which titles the tab after the assemblies. Route it through ProjectExport like the single-assembly path already is, leaving one runner and one matcher behind every flow that decompiles whole assemblies to disk. Save Code keeps letting the user name the .sln (the Export Project dialog only asks for a folder and derives the name from it), so the export options now carry an optional solution file name; unset means the old folder-derived name. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Commands/SaveCodeProjectExportTests.cs | 57 +++++++++- ILSpy/Commands/FileCommands.cs | 4 +- ILSpy/Commands/ProjectExport.cs | 101 +++++++++++++----- ILSpy/Commands/ProjectExportOptions.cs | 15 ++- ILSpy/Commands/ProjectExporter.cs | 8 +- ILSpy/Commands/SaveCodeContextMenuEntry.cs | 9 +- ILSpy/Commands/SolutionExport.cs | 98 ----------------- 7 files changed, 152 insertions(+), 140 deletions(-) delete mode 100644 ILSpy/Commands/SolutionExport.cs diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs index 451fe4441e..4bd486e923 100644 --- a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs +++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs @@ -38,10 +38,12 @@ namespace ICSharpCode.ILSpy.Tests.Commands; /// /// Every File -> Save Code path for an assembly runs behind a cancellable progress overlay, never a -/// bare Task.Run: the .csproj export goes through -/// (the Export Project command's frozen determinate-progress tab), and the single-file save goes through -/// (the same overlay normal decompilation -/// uses). These assert both paths write their output and surface a report/breadcrumb in a tab. +/// bare Task.Run, and every one that decompiles a whole assembly shares the Export Project command's +/// frozen determinate-progress tab: for a +/// .csproj, for several assemblies as a .sln. The +/// single-file save goes through (the +/// same overlay normal decompilation uses). These assert each path writes its output and surfaces a +/// report/breadcrumb in a tab. /// [TestFixture] public class SaveCodeProjectExportTests @@ -83,6 +85,53 @@ await ProjectExport.ExportSingleAssemblyAsync( } } + [AvaloniaTest] + public async Task Save_Several_Assemblies_Exports_A_Solution_Through_The_Export_Project_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var assemblies = new[] { + await vm.OpenFixtureAsync("FixtureA"), + await vm.OpenFixtureAsync("FixtureB"), + }; + var dock = vm.DockWorkspace; + + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveSln_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + // A name the output folder does not imply: Save Code lets the user pick the .sln file itself, + // unlike the Export Project dialog, which derives the name from the chosen folder. + var solutionPath = Path.Combine(tempDir, "Picked.sln"); + try + { + await ProjectExport.ExportSolutionAsync(assemblies, solutionPath, settings, language, dock); + + File.Exists(solutionPath).Should().BeTrue( + "the solution must land on the exact path picked in Save Code, not one derived from the folder"); + foreach (var a in assemblies) + { + Directory.EnumerateFiles(Path.Combine(tempDir, a.ShortName), "*.csproj").Should().HaveCount(1, + $"each exported assembly gets its own project ({a.ShortName})"); + } + + var exportTab = dock.Documents!.VisibleDockables!.OfType() + .Select(t => t.Content).OfType() + .FirstOrDefault(d => d.Title == string.Join(", ", assemblies.Select(a => a.Text))); + exportTab.Should().NotBeNull( + "a solution export tab is titled after the assemblies being exported -- the same however it was started (Save Code or Export Project)"); + exportTab!.Text.Should().Contain("Created the Visual Studio Solution file", + "the export runs in its own progress tab and reports there"); + } + finally + { + try + { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort */ } + } + } + [AvaloniaTest] public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab() { diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 016671924f..d55610d5c1 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -240,9 +240,9 @@ public override async void Execute(object? parameter) { // Several selected assemblies export a Visual Studio solution (one project each), // matching the Save Code context-menu entry. - if (SolutionExport.TryGetAssemblies(assemblyTreeModel.SelectedItems, out var assemblies)) + if (ProjectExport.TryGetSolutionAssemblies(assemblyTreeModel.SelectedItems, out var assemblies)) { - await SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace); + await ProjectExport.PromptAndExportSolutionAsync(assemblies, languageService.CurrentLanguage, dockWorkspace); return; } diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index 9bb892e369..d27906ce94 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -26,6 +26,7 @@ using global::Avalonia.Controls.ApplicationLifetimes; using ICSharpCode.Decompiler; +using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; @@ -40,10 +41,18 @@ namespace ICSharpCode.ILSpy.Commands { /// - /// The shared launcher behind the dedicated "Export Project/Solution..." entry (File menu + - /// assembly context menu): recognises an exportable selection, shows - /// , then runs on a settings - /// clone behind the tab's cancellable progress UI and reports into the active decompiler tab. + /// The shared launcher for every flow that decompiles whole assemblies to disk. All of them + /// recognise their selection with and run + /// on a settings clone behind a frozen progress tab + /// (), differing only in how the target and options are chosen: + /// + /// "Export Project/Solution..." (File menu + assembly context menu) asks + /// for an output folder and per-run overrides. + /// Save Code on one assembly, with a project extension picked in the file dialog, exports + /// that project with the live settings (). + /// Save Code on several assemblies exports a solution to a picked .sln path, again + /// with the live settings (). + /// /// internal static class ProjectExport { @@ -65,6 +74,14 @@ public static bool TryGetExportableAssemblies(IReadOnlyList? node return true; } + /// + /// True when is the selection shape Save Code maps onto a solution: + /// several assembly nodes that all loaded as valid assemblies. + /// + public static bool TryGetSolutionAssemblies(IReadOnlyList? nodes, + out List assemblies) + => TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode; + public static async Task PromptAndExportAsync(IReadOnlyList assemblies, bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService) { @@ -82,13 +99,8 @@ public static async Task PromptAndExportAsync(IReadOnlyList asse } /// - /// Exports a single assembly as a decompiled project into , using - /// the current decompiler settings (no export-dialog overrides, no PDB, no strong-name key). This is - /// the File -> Save Code -> .csproj path: it reuses the same + - /// frozen-progress-tab machinery as the Export Project command, so a large assembly reports real - /// progress and can be cancelled, instead of running silently. The tab is titled the same way as the - /// Export Project command (see ) so the same operation reads the same - /// however it was started. + /// Exports a single assembly as a decompiled project into . + /// This is the File -> Save Code path for a project extension. /// public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string outputDirectory, DecompilerSettings settings, Language language, DockWorkspace dockWorkspace) @@ -97,9 +109,52 @@ public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string out ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(dockWorkspace); - // Mirror the live settings into the options so ProjectExporter.ApplyOverrides is a no-op and the - // output matches the plain "Save Code" behaviour exactly, just with progress now surfaced. - var options = new ProjectExportOptions( + return RunExportAsync(new List { assembly }, solutionMode: false, + OptionsFrom(settings, outputDirectory), settings.Clone(), language, dockWorkspace); + } + + /// + /// Prompts for a target .sln file and exports into it, one + /// decompiled project each. This is the File -> Save Code path for a multi-assembly selection. + /// Does nothing if the user cancels the picker. + /// + public static async Task PromptAndExportSolutionAsync(IReadOnlyList assemblies, + Language language, DockWorkspace dockWorkspace) + { + var path = await FilePickers.SaveAsync( + Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", + Resources._SaveCode).ConfigureAwait(true); + if (string.IsNullOrEmpty(path)) + return; + + var settings = AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() + ?? new DecompilerSettings(); + await ExportSolutionAsync(assemblies, path, settings, language, dockWorkspace).ConfigureAwait(true); + } + + /// + /// Exports as a solution written to , + /// one decompiled project each. Public so tests (and scripted callers) can bypass the file picker. + /// + public static Task ExportSolutionAsync(IReadOnlyList assemblies, string solutionPath, + DecompilerSettings settings, Language language, DockWorkspace dockWorkspace) + { + ArgumentNullException.ThrowIfNull(assemblies); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(dockWorkspace); + + var options = OptionsFrom(settings, Path.GetDirectoryName(solutionPath) ?? string.Empty, + Path.GetFileName(solutionPath)); + return RunExportAsync(assemblies, solutionMode: true, options, settings.Clone(), language, dockWorkspace); + } + + // The Save Code paths take the settings as they stand instead of asking for per-run overrides, so + // mirror those settings into the options: ProjectExporter.ApplyOverrides then leaves the settings + // clone alone and the output matches a plain decompile, only with progress surfaced. No PDB and no + // strong-name key either -- both are dialog-only features. + static ProjectExportOptions OptionsFrom(DecompilerSettings settings, string outputDirectory, + string? solutionFileName = null) + => new( OutputDirectory: outputDirectory, UseSdkStyleProjectFormat: settings.UseSdkStyleProjectFormat, UseNestedDirectoriesForNamespaces: settings.UseNestedDirectoriesForNamespaces, @@ -108,24 +163,22 @@ public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string out UseDebugSymbols: settings.UseDebugSymbols, StrongNameKeyFile: null, GeneratePdb: false, - EmbedSourceFilesInPdb: false); - return RunExportAsync(new List { assembly }, solutionMode: false, options, - settings.Clone(), language, dockWorkspace); - } + EmbedSourceFilesInPdb: false, + SolutionFileName: solutionFileName); // Runs the export behind a dedicated frozen tab (so browsing the tree while it runs can't cancel it) - // and reports the result there, with an Open-folder button on success. Shared by the Export Project - // command and the Save Code -> .csproj path. The tab is titled after the assemblies being exported, - // joining their full tree-node labels the same way DecompilerTabPageModel.ComposeBaseTitle titles a - // multi-node decompile tab -- so a single-assembly export reads as that assembly and a solution - // export as its members, ellipsised on the tab with the full list shown as a tooltip. + // and reports the result there, with an Open-folder button on success. The tab is titled after the + // assemblies being exported, joining their full tree-node labels the same way + // DecompilerTabPageModel.ComposeBaseTitle titles a multi-node decompile tab -- so a single-assembly + // export reads as that assembly and a solution export as its members, ellipsised on the tab with the + // full list shown as a tooltip. static Task RunExportAsync(IReadOnlyList assemblies, bool solutionMode, ProjectExportOptions options, DecompilerSettings settingsClone, Language language, DockWorkspace dockWorkspace) { var title = assemblies.Count > 0 ? string.Join(", ", assemblies.Select(a => a.Text)) - : ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution; + : Resources.ExportProjectSolution; return dockWorkspace.RunInNewTabAsync(title, async (token, progress) => { var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, progress, token) .ConfigureAwait(false); diff --git a/ILSpy/Commands/ProjectExportOptions.cs b/ILSpy/Commands/ProjectExportOptions.cs index 4f504bf0bd..cbade909db 100644 --- a/ILSpy/Commands/ProjectExportOptions.cs +++ b/ILSpy/Commands/ProjectExportOptions.cs @@ -19,10 +19,16 @@ namespace ICSharpCode.ILSpy.Commands { /// - /// The configuration chosen in the Export Project/Solution dialog and consumed by - /// . The format/decompiler flags are applied onto a clone of the - /// live decompiler settings (never the persisted instance). + /// The configuration for one export run, consumed by : chosen in the + /// Export Project/Solution dialog, or mirrored off the live settings by the Save Code paths. The + /// format/decompiler flags are applied onto a clone of the live decompiler settings (never the + /// persisted instance). /// + /// + /// In solution mode, the name of the .sln to write inside . + /// null names it after that directory, which is what the dialog wants (it only asks for a + /// folder); Save Code sets it, because there the user picks the solution file itself. + /// public sealed record ProjectExportOptions( string OutputDirectory, bool UseSdkStyleProjectFormat, @@ -32,5 +38,6 @@ public sealed record ProjectExportOptions( bool UseDebugSymbols, string? StrongNameKeyFile, bool GeneratePdb, - bool EmbedSourceFilesInPdb); + bool EmbedSourceFilesInPdb, + string? SolutionFileName = null); } diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs index e5309c3861..bdff0f6c15 100644 --- a/ILSpy/Commands/ProjectExporter.cs +++ b/ILSpy/Commands/ProjectExporter.cs @@ -57,7 +57,8 @@ public static async Task ExportAsync( if (solutionMode) { - var solutionFilePath = Path.Combine(options.OutputDirectory, SolutionFileName(options.OutputDirectory)); + var solutionFilePath = Path.Combine(options.OutputDirectory, + options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory)); var solution = await SolutionWriter.CreateSolutionAsync( solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile, progress) .ConfigureAwait(false); @@ -159,8 +160,9 @@ static void ApplyOverrides(DecompilerSettings settings, ProjectExportOptions opt settings.UseDebugSymbols = options.UseDebugSymbols; } - // The .sln is named after the chosen output folder, falling back to "Solution.sln". - static string SolutionFileName(string outputDirectory) + // The default .sln name when the caller does not supply one: after the chosen output folder, + // falling back to "Solution.sln". + static string SolutionFileNameFor(string outputDirectory) { var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs index f953997922..a1f728a4c7 100644 --- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs +++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs @@ -17,7 +17,6 @@ // DEALINGS IN THE SOFTWARE. using System.Composition; -using System.Linq; using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpyX.TreeView; @@ -35,7 +34,7 @@ namespace ICSharpCode.ILSpy.Commands /// AssemblyTreeNode drives project / single-file selection, ResourceTreeNode /// writes raw bytes, every other node falls through to the generic single-file decompile. /// Several selected assemblies export a Visual Studio solution (one decompiled project - /// each) via . + /// each) via . /// /// [ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 300)] @@ -62,9 +61,9 @@ public void Execute(TextViewContext context) if (nodes is not { Length: > 0 }) return; - if (SolutionExport.TryGetAssemblies(nodes, out var assemblies)) + if (ProjectExport.TryGetSolutionAssemblies(nodes, out var assemblies)) { - SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions(); + ProjectExport.PromptAndExportSolutionAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions(); return; } @@ -84,7 +83,7 @@ static bool CanExecute(SharpTreeNode[]? selectedNodes) return false; if (selectedNodes.Length == 1) return selectedNodes[0] is ILSpyTreeNode; - return SolutionExport.TryGetAssemblies(selectedNodes, out _); + return ProjectExport.TryGetSolutionAssemblies(selectedNodes, out _); } } } diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs deleted file mode 100644 index 4dce21bd13..0000000000 --- a/ILSpy/Commands/SolutionExport.cs +++ /dev/null @@ -1,98 +0,0 @@ -// 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.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -using ICSharpCode.ILSpy.Properties; -using ICSharpCode.ILSpyX; -using ICSharpCode.ILSpyX.TreeView; - -using ICSharpCode.ILSpy.Docking; -using ICSharpCode.ILSpy.Languages; -using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; -using ICSharpCode.ILSpy.Util; - -namespace ICSharpCode.ILSpy.Commands -{ - /// - /// The shared "export several assemblies as a Visual Studio solution" flow behind both the - /// File → Save Code command and the Save Code context-menu entry: it recognises a - /// solution-eligible selection, prompts for the .sln path, runs - /// behind the tab's cancellable progress UI, and surfaces the - /// status report in the active decompiler tab. - /// - internal static class SolutionExport - { - /// - /// True when is several assembly nodes that all loaded as valid - /// assemblies — the only selection shape that maps onto a multi-project solution. - /// - public static bool TryGetAssemblies(IReadOnlyList? nodes, out List assemblies) - { - assemblies = new List(); - if (nodes is not { Count: > 1 }) - return false; - if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) - return false; - assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); - return true; - } - - /// - /// Prompts for a target .sln file and exports into it, - /// one decompiled project each. The export runs in its own frozen tab so browsing the tree - /// can't cancel it, and the status report lands there. Does nothing if the user cancels the picker. - /// - public static async Task PromptAndExportAsync(IReadOnlyList assemblies, - Language language, DockWorkspace dockWorkspace) - { - var path = await FilePickers.SaveAsync( - Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", Resources._SaveCode) - .ConfigureAwait(true); - if (string.IsNullOrEmpty(path)) - return; - - // Snapshot the user's current decompiler settings for the whole export, like the - // per-project export path does. - var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() - ?? new ICSharpCode.Decompiler.DecompilerSettings(); - - // Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it. - await dockWorkspace.RunInNewTabAsync("Exporting solution", async (token, progress) => { - var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token, - settings, strongNameKeyFile: null, progress: progress) - .ConfigureAwait(false); - var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode }; - o.Write(result.StatusText); - o.WriteLine(); - if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory) - { - o.AddOpenFolderButton(directory); - } - return o; - }).ConfigureAwait(true); - } - - } -} From 0cad094928a0b3c7627a9b9afe2159e6d842744c Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 15 Jul 2026 22:55:30 +0200 Subject: [PATCH 3/4] Export what loaded, and let the user name the solution Two gaps in the export paths, both visible from the same selection. A selection holding an assembly that failed to load was turned away by TryGetExportableAssemblies, so Ctrl+S fell through to the single-node save and quietly wrote just the focused assembly -- the rest of the selection vanished with no report. The predicate now only insists that something in the selection loaded, and the exporter skips what it cannot decompile and names it in the status report. That is also what the dialog always assumed: its "not a valid assembly" row badge was unreachable, because no selection containing one could get that far. The dialog asks for an output folder and derived the .sln name from it, while Save Code lets the user name the file. Now the dialog offers the name too, in solution mode, defaulting (via the placeholder) to the folder- derived name the exporter would pick anyway. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Commands/SaveCodeProjectExportTests.cs | 32 +++++ ILSpy.Tests/FixtureAssembly.cs | 75 +++++++++++ .../Languages/ExportPreviewRowTests.cs | 124 +++++++++++++++++- .../Languages/ProjectExportRunnerTests.cs | 110 ++++++++++++++++ ILSpy/Commands/ProjectExport.cs | 38 +++++- ILSpy/Commands/ProjectExporter.cs | 77 +++++++++-- ILSpy/Views/ExportProjectDialog.axaml | 10 +- ILSpy/Views/ExportProjectDialog.axaml.cs | 39 +++++- 8 files changed, 484 insertions(+), 21 deletions(-) diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs index 4bd486e923..89fc548660 100644 --- a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs +++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs @@ -31,6 +31,7 @@ using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpyX.TreeView; using NUnit.Framework; @@ -132,6 +133,37 @@ await vm.OpenFixtureAsync("FixtureB"), } } + [AvaloniaTest] + public async Task A_Failed_Load_In_The_Selection_Does_Not_Disqualify_The_Solution_Export() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var broken = await vm.OpenBrokenFixtureAsync(); + + // The selection Ctrl+S sees: two assembly nodes, one of which failed to load. Rejecting it + // here is what used to make Save Code fall through and quietly save the focused assembly + // alone; the exporter skips the unloadable one and reports it instead. + var nodes = new SharpTreeNode[] { new AssemblyTreeNode(good), new AssemblyTreeNode(broken) }; + + ProjectExport.TryGetSolutionAssemblies(nodes, out var assemblies).Should().BeTrue( + "an assembly that failed to load must not disqualify the whole selection"); + assemblies.Should().BeEquivalentTo([good, broken], + "the exporter decides what to skip, so it has to see the failed load to report it"); + } + + [AvaloniaTest] + public async Task A_Selection_Where_Nothing_Loaded_Has_Nothing_To_Export() + { + var (_, vm) = await TestHarness.BootAsync(); + var broken = await vm.OpenBrokenFixtureAsync("BrokenA"); + var alsoBroken = await vm.OpenBrokenFixtureAsync("BrokenB"); + + var nodes = new SharpTreeNode[] { new AssemblyTreeNode(broken), new AssemblyTreeNode(alsoBroken) }; + + ProjectExport.TryGetSolutionAssemblies(nodes, out _).Should().BeFalse( + "a solution of nothing but failed loads would be empty; Save Code leaves the selection to the single-node path"); + } + [AvaloniaTest] public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab() { diff --git a/ILSpy.Tests/FixtureAssembly.cs b/ILSpy.Tests/FixtureAssembly.cs index 91e7b2d0f5..ca16e24833 100644 --- a/ILSpy.Tests/FixtureAssembly.cs +++ b/ILSpy.Tests/FixtureAssembly.cs @@ -17,9 +17,13 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; using System.Threading.Tasks; using ICSharpCode.ILSpyX; @@ -93,4 +97,75 @@ public static Task OpenFixtureAsync(this MainWindowViewModel vm, ArgumentNullException.ThrowIfNull(vm); return vm.OpenAssemblyAsync(Emit(name)); } + + /// + /// Writes a standalone portable PDB to a temp file and returns its path. It carries a metadata + /// root but no PE image, so it loads as a metadata-only file -- the "(Debug Metadata)" entry + /// ILSpy shows for a .pdb opened on its own. Callers use it for the selection member that loads + /// successfully yet holds nothing to decompile. + /// + public static string EmitStandalonePdb(string name) + { + // An all-zero row count set describes an empty type system, which is all this needs: the file + // only has to be readable as metadata, not to match any particular assembly. + var pdbBuilder = new PortablePdbBuilder( + new MetadataBuilder(), ImmutableArray.CreateRange(new int[MetadataTokens.TableCount]), default); + var blob = new BlobBuilder(); + pdbBuilder.Serialize(blob); + + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyPdbFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"{name}.pdb"); + File.WriteAllBytes(path, blob.ToArray()); + return path; + } + + /// + /// Opens a standalone portable PDB, returning it once it has loaded. It loads successfully but is + /// metadata-only, so the export paths have to tell it apart from both a real assembly and a file + /// that failed to load. + /// + public static async Task OpenMetadataOnlyFixtureAsync(this MainWindowViewModel vm, string name = "MetadataOnly") + { + ArgumentNullException.ThrowIfNull(vm); + + var path = EmitStandalonePdb(name); + vm.AssemblyTreeModel.OpenFiles([path]); + await Waiters.WaitForAsync( + () => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Any(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)), + description: $"metadata-only file '{path}' to appear in the active list"); + + var loaded = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .First(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)); + await loaded.GetLoadResultAsync(); + return loaded; + } + + /// + /// Opens a file that is not a PE image at all, returning it once the load has failed. Callers use + /// it to build the mixed selections the export paths have to cope with. Unlike + /// this waits on + /// rather than the load result, which for such a file rethrows the load failure. + /// + public static async Task OpenBrokenFixtureAsync(this MainWindowViewModel vm, string name = "Broken") + { + ArgumentNullException.ThrowIfNull(vm); + + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyBrokenFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"{name}.dll"); + await File.WriteAllTextAsync(path, "not a real PE file"); + + vm.AssemblyTreeModel.OpenFiles([path]); + await Waiters.WaitForAsync( + () => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Any(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)), + description: $"broken assembly '{path}' to appear in the active list"); + + var loaded = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .First(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)); + await Waiters.WaitForAsync(() => loaded.HasLoadError, description: $"'{path}' to fail loading"); + return loaded; + } } diff --git a/ILSpy.Tests/Languages/ExportPreviewRowTests.cs b/ILSpy.Tests/Languages/ExportPreviewRowTests.cs index 42652d78c9..0f307357d3 100644 --- a/ILSpy.Tests/Languages/ExportPreviewRowTests.cs +++ b/ILSpy.Tests/Languages/ExportPreviewRowTests.cs @@ -16,13 +16,20 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia.Headless.NUnit; +using Avalonia.Threading; using AwesomeAssertions; +using ICSharpCode.ILSpyX; + +using ICSharpCode.ILSpy; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; using ICSharpCode.ILSpy.Views; using NUnit.Framework; @@ -30,9 +37,10 @@ namespace ICSharpCode.ILSpy.Tests.Languages; /// -/// The Export Project/Solution dialog's preview computation (extracted as a pure static so it is -/// testable without the window): per-assembly project name, target subdirectory, and the -/// invalid / duplicate-name / PDB-eligible badges. +/// The Export Project/Solution dialog's choices, extracted as pure statics so they are testable +/// without the window: the preview rows (per-assembly project name, target subdirectory, and the +/// invalid / duplicate-name / PDB-eligible badges) and the solution file name the user may type in +/// solution mode. /// [TestFixture] public class ExportPreviewRowTests @@ -82,4 +90,114 @@ public async Task Duplicate_Short_Names_Are_Flagged_On_Every_Affected_Row() rows.Should().OnlyContain(r => r.HasDuplicateShortName); rows.Should().OnlyContain(r => r.BadgeText.Contains("duplicate name")); } + + [AvaloniaTest] + public async Task An_Assembly_That_Failed_To_Load_Is_Badged_As_Invalid() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var broken = await vm.OpenBrokenFixtureAsync(); + + var rows = ExportProjectDialog.BuildPreviewRows([good, broken], solutionMode: true); + + rows.Should().HaveCount(2); + rows.Single(r => r.ProjectName.StartsWith(broken.ShortName)).BadgeText.Should().Contain("not a valid assembly", + "the dialog has to say which rows the export will skip"); + rows.Single(r => r.ProjectName.StartsWith(good.ShortName)).IsValidAssembly.Should().BeTrue(); + } + + [AvaloniaTest] + public async Task The_Export_Flow_Settles_Loads_Before_The_Dialog_Reads_Them() + { + var (_, vm) = await TestHarness.BootAsync(); + // A LoadedAssembly loads lazily -- the tree builds its entries en masse and only the first await + // starts the work -- so a selected-but-never-opened assembly reaches the export flow with its + // load untouched. Its preview-row badges are read off the load result, so the flow has to settle + // the load first; otherwise the dialog blocks the UI thread forcing one as it builds the rows. + var assembly = new LoadedAssembly(vm.AssemblyTreeModel.AssemblyList!, FixtureAssembly.Emit("FixturePreview")); + assembly.IsLoadedAsValidAssembly.Should().BeFalse("nothing has triggered the load yet"); + + await ProjectExport.EnsureAssembliesLoadedAsync([assembly]); + + assembly.IsLoadedAsValidAssembly.Should().BeTrue( + "the export flow settles every selected assembly's load up front"); + ExportProjectDialog.BuildPreviewRows([assembly], solutionMode: false) + .Single().IsValidAssembly.Should().BeTrue("the dialog now badges it from a completed load"); + } + + [AvaloniaTest] + public async Task Solution_Name_Field_Is_Offered_Only_In_Solution_Mode() + { + var (_, vm) = await TestHarness.BootAsync(); + var a = await vm.OpenFixtureAsync("FixtureA"); + var b = await vm.OpenFixtureAsync("FixtureB"); + var settings = AppComposition.Current.GetExport(); + + var solutionDialog = new ExportProjectDialog(settings, [a, b], solutionMode: true); + solutionDialog.Show(); + solutionDialog.SolutionNamePanel.IsVisible.Should().BeTrue( + "the .sln is the user's to name when several assemblies are exported"); + solutionDialog.Capture("solution-mode"); + solutionDialog.Close(); + + var projectDialog = new ExportProjectDialog(settings, [a], solutionMode: false); + projectDialog.Show(); + projectDialog.SolutionNamePanel.IsVisible.Should().BeFalse( + "a single project export writes no solution, so there is no name to ask for"); + projectDialog.Capture("project-mode"); + projectDialog.Close(); + } + + [AvaloniaTest] + public async Task Export_Dialog_Fits_Its_Content_Without_Scrolling() + { + var (_, vm) = await TestHarness.BootAsync(); + var a = await vm.OpenFixtureAsync("FixtureA"); + var b = await vm.OpenFixtureAsync("FixtureB"); + var settings = AppComposition.Current.GetExport(); + + // Solution mode is the tall case: it carries the solution-name field on top of everything + // project mode shows. + foreach (var (mode, assemblies) in new[] { + ("solution", new[] { a, b }), + ("project", new[] { a }), + }) + { + var dialog = new ExportProjectDialog(settings, assemblies, solutionMode: mode == "solution"); + dialog.Show(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + + dialog.OptionsScroll.Extent.Height.Should().BeLessThanOrEqualTo(dialog.OptionsScroll.Viewport.Height + 0.5, + $"every option must be reachable without scrolling in {mode} mode " + + $"(content {dialog.OptionsScroll.Extent.Height}, viewport {dialog.OptionsScroll.Viewport.Height})"); + + dialog.Close(); + } + } + + [TestCase("MySolution", "MySolution.sln", TestName = "A typed name gains the .sln extension")] + [TestCase("MySolution.sln", "MySolution.sln", TestName = "An already-qualified name is not doubled up")] + [TestCase(" Spaced ", "Spaced.sln", TestName = "Surrounding whitespace is trimmed")] + [TestCase("", null, TestName = "A blank name defers to the folder-derived default")] + [TestCase(" ", null, TestName = "A whitespace-only name defers to the folder-derived default")] + [TestCase(null, null, TestName = "An unset name defers to the folder-derived default")] + // Stripping the extension the user typed leaves nothing behind, which CleanUpFileName would turn + // into "-", exporting "-.sln" for what reads as a request for the default. + [TestCase(".sln", null, TestName = "A bare extension defers to the folder-derived default")] + [TestCase(" .SLN ", null, TestName = "A padded bare extension defers to the folder-derived default")] + public void Typed_Solution_Names_Are_Normalized(string? typed, string? expected) + { + ExportProjectDialog.NormalizeSolutionFileName(typed).Should().Be(expected); + } + + [Test] + public void A_Typed_Solution_Name_Cannot_Escape_The_Output_Folder() + { + var normalized = ExportProjectDialog.NormalizeSolutionFileName("../../etc/passwd"); + + normalized.Should().NotBeNull(); + normalized.Should().EndWith(".sln"); + Path.GetFileName(normalized).Should().Be(normalized, + "the name is combined with the chosen output directory, so it must stay a bare file name"); + } } diff --git a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs index c135c614b8..a4ab7d6ba5 100644 --- a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs +++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs @@ -134,6 +134,116 @@ public async Task Solution_Mode_Writes_Sln_And_Projects() } } + [AvaloniaTest] + public async Task Solution_Mode_Skips_Assemblies_That_Failed_To_Load() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var broken = await vm.OpenBrokenFixtureAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSkipped_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([good, broken], solutionMode: true, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeTrue( + "one unloadable assembly must not sink the export of the ones that did load. Status:\n" + result.StatusText); + Directory.EnumerateFiles(Path.Combine(tempDir, good.ShortName), "*.csproj").Should().HaveCount(1, + "the assembly that loaded is still exported"); + Directory.Exists(Path.Combine(tempDir, broken.ShortName)).Should().BeFalse( + "there is nothing to decompile for an assembly that failed to load"); + result.StatusText.Should().Contain(broken.ShortName).And.Contain("failed to load", + "a skipped assembly is named in the report -- dropping it silently is what this replaces"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Export_Loads_An_Assembly_Whose_Load_Has_Not_Started() + { + var (_, vm) = await TestHarness.BootAsync(); + // A LoadedAssembly loads lazily: the tree constructs entries en masse and only the first + // await kicks the work off, so a selected-but-never-opened assembly reaches the exporter with + // its load untouched. Filtering the selection on a non-blocking status poll drops it as if it + // had failed, and the export writes nothing. + var assembly = new LoadedAssembly(vm.AssemblyTreeModel.AssemblyList!, FixtureAssembly.Emit("FixtureLazy")); + assembly.IsLoadedAsValidAssembly.Should().BeFalse("nothing has triggered the load yet"); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjLazy_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([assembly], solutionMode: false, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeTrue( + "an assembly whose load has not been started yet decompiles fine once awaited. Status:\n" + result.StatusText); + Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1); + result.StatusText.Should().NotContain("failed to load", + "a load that had not started is not a load that failed"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Solution_Mode_Reports_A_Metadata_Only_File_As_Such() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var metadataOnly = await vm.OpenMetadataOnlyFixtureAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjMetaOnly_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([good, metadataOnly], solutionMode: true, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeTrue( + "a metadata-only file must not sink the export of the assembly that did load. Status:\n" + result.StatusText); + Directory.EnumerateFiles(Path.Combine(tempDir, good.ShortName), "*.csproj").Should().HaveCount(1); + result.StatusText.Should().Contain(metadataOnly.ShortName, + "a skipped file is named in the report"); + result.StatusText.Should().NotContain("failed to load", + "the file loaded; it just holds no code, and reporting a load failure sends the user " + + "looking for a corrupt file that is not there"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Export_Reports_Failure_When_Nothing_In_The_Selection_Loaded() + { + var (_, vm) = await TestHarness.BootAsync(); + var broken = await vm.OpenBrokenFixtureAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjNoneLoaded_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([broken], solutionMode: false, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeFalse("there is nothing left to export once the only assembly is skipped"); + result.StatusText.Should().Contain(broken.ShortName).And.Contain("failed to load"); + } + finally + { + TryDelete(tempDir); + } + } + [AvaloniaTest] public async Task StrongNameKeyFile_Is_Copied_Into_Project() { diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index d27906ce94..c8ba8ea0e4 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -57,8 +57,11 @@ namespace ICSharpCode.ILSpy.Commands internal static class ProjectExport { /// - /// True when is one or more assembly nodes that all loaded as valid - /// assemblies. is set when more than one is selected. + /// True when is one or more assembly nodes, at least one of which loaded + /// as a valid assembly. Ones that failed to load stay in : the dialog + /// badges them and skips them with a line in the report, so a mixed + /// selection exports what it can instead of being turned away here. + /// is set when more than one node is selected. /// public static bool TryGetExportableAssemblies(IReadOnlyList? nodes, out List assemblies, out bool solutionMode) @@ -67,21 +70,38 @@ public static bool TryGetExportableAssemblies(IReadOnlyList? node solutionMode = false; if (nodes is not { Count: > 0 }) return false; - if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) + if (!nodes.All(n => n is AssemblyTreeNode)) return false; - assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); - solutionMode = assemblies.Count > 1; + + var selected = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); + // Nothing to write when every selected assembly failed to load. + if (!selected.Any(a => a.IsLoadedAsValidAssembly)) + return false; + + assemblies = selected; + solutionMode = selected.Count > 1; return true; } /// /// True when is the selection shape Save Code maps onto a solution: - /// several assembly nodes that all loaded as valid assemblies. + /// several assembly nodes, on the terms sets out -- the + /// ones that did not load ride along in to be skipped and reported + /// by the exporter, rather than costing the whole selection its solution export. /// public static bool TryGetSolutionAssemblies(IReadOnlyList? nodes, out List assemblies) => TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode; + /// + /// Awaits every assembly's load so a caller can read settled load state -- validity, metadata, + /// PDB eligibility -- without triggering or blocking on the lazy load itself. Uses the load + /// accessor that swallows load failures, so a broken assembly in the selection completes here + /// rather than faulting the whole batch. + /// + internal static Task EnsureAssembliesLoadedAsync(IReadOnlyList assemblies) + => Task.WhenAll(assemblies.Select(a => a.GetMetadataFileOrNullAsync())); + public static async Task PromptAndExportAsync(IReadOnlyList assemblies, bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService) { @@ -89,6 +109,12 @@ public static async Task PromptAndExportAsync(IReadOnlyList asse if (owner == null) return; + // Settle every selected assembly's load before building the dialog: its preview rows badge + // each one (valid / not a valid assembly / PDB-eligible) off the load result. Awaiting here + // means those reads see a completed load, instead of the dialog blocking the UI thread to + // force one as it builds the rows. + await EnsureAssembliesLoadedAsync(assemblies).ConfigureAwait(true); + var dialog = new ExportProjectDialog(settingsService, assemblies, solutionMode); var options = await dialog.ShowDialog(owner).ConfigureAwait(true); if (options is null || string.IsNullOrEmpty(options.OutputDirectory)) diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs index bdff0f6c15..ddff5bdf75 100644 --- a/ILSpy/Commands/ProjectExporter.cs +++ b/ILSpy/Commands/ProjectExporter.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -37,9 +38,10 @@ namespace ICSharpCode.ILSpy.Commands /// /// The UI-agnostic engine behind the Export Project/Solution dialog. Unifies single-assembly /// project export and multi-assembly solution export (the latter via ), - /// applies the dialog's overrides onto a settings clone, and optionally emits a portable PDB per - /// assembly. Returns a the caller surfaces in the text view. - /// Kept separate from the dialog so it is headless-testable. + /// applies the dialog's overrides onto a settings clone, skips (and reports) the entries with no code + /// behind them, and optionally emits a portable PDB per assembly. Returns a + /// the caller surfaces in the text view. Kept separate from the + /// dialog so it is headless-testable. /// internal static class ProjectExporter { @@ -55,26 +57,80 @@ public static async Task ExportAsync( ApplyOverrides(settingsClone, options); + // Resolve each entry by awaiting its load. IsLoadedAsValidAssembly cannot stand in for this: + // it is a non-blocking status poll that reads false for a load still running or not yet + // started (LoadedAssembly builds its entries lazily), so filtering on it here would drop + // assemblies that decompile perfectly well once awaited. The callers' selection predicate has + // to poll -- it answers IsEnabled on the UI thread -- but this runs off it and can wait. + var loaded = await Task.WhenAll(assemblies.Select(async a => ( + Assembly: a, + File: await a.GetMetadataFileOrNullAsync().ConfigureAwait(false) + ))).ConfigureAwait(false); + + // Anything without a PE image behind it has nothing to decompile: a file that failed to load, + // or one that carries metadata only (a standalone PDB, say). Leave those out and name them in + // the report, so a mixed selection still exports what it can and the user is told what is + // missing rather than having to notice it. + var exportable = loaded.Where(e => e.File is { IsMetadataOnly: false }).Select(e => e.Assembly).ToList(); + var skipReport = SkipReport(loaded.Where(e => e.File is not { IsMetadataOnly: false })); + if (exportable.Count == 0) + { + return new SolutionExportResult(false, + "Nothing to export." + Environment.NewLine + skipReport); + } + if (solutionMode) { var solutionFilePath = Path.Combine(options.OutputDirectory, options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory)); var solution = await SolutionWriter.CreateSolutionAsync( - solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile, progress) + solutionFilePath, language, exportable, ct, settingsClone, options.StrongNameKeyFile, progress) .ConfigureAwait(false); var report = new StringBuilder(solution.StatusText); if (options.GeneratePdb && solution.Success) { - await Task.Run(() => GeneratePdbs(assemblies, + await Task.Run(() => GeneratePdbs(exportable, a => Path.Combine(options.OutputDirectory, a.ShortName), settingsClone, options, report, ct), ct) .ConfigureAwait(false); } + AppendSkipReport(report, skipReport); return new SolutionExportResult(solution.Success, report.ToString()); } - return await Task.Run(() => ExportProject(assemblies[0], options, settingsClone, language, progress, ct), ct) + var projectResult = await Task + .Run(() => ExportProject(exportable[0], options, settingsClone, language, progress, ct), ct) .ConfigureAwait(false); + if (skipReport.Length == 0) + return projectResult; + + var projectReport = new StringBuilder(projectResult.StatusText); + AppendSkipReport(projectReport, skipReport); + return projectResult with { StatusText = projectReport.ToString() }; + } + + // One line per assembly left out of the export, saying which of the two reasons applies: a file + // that never loaded is a different problem from one that loaded and holds no code, and sending + // the user after a corrupt file that is not there wastes their time. + static string SkipReport(IEnumerable<(LoadedAssembly Assembly, MetadataFile? File)> skipped) + { + var report = new StringBuilder(); + foreach (var (assembly, file) in skipped) + { + var reason = file == null + ? "the assembly failed to load" + : "it holds metadata only, with no code to decompile"; + report.AppendLine($"Skipped '{assembly.ShortName}': {reason}."); + } + return report.ToString(); + } + + static void AppendSkipReport(StringBuilder report, string skipReport) + { + if (skipReport.Length == 0) + return; + report.AppendLine(); + report.Append(skipReport); } static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options, @@ -160,9 +216,12 @@ static void ApplyOverrides(DecompilerSettings settings, ProjectExportOptions opt settings.UseDebugSymbols = options.UseDebugSymbols; } - // The default .sln name when the caller does not supply one: after the chosen output folder, - // falling back to "Solution.sln". - static string SolutionFileNameFor(string outputDirectory) + /// + /// The .sln name used when is unset: + /// after the chosen output folder, falling back to "Solution.sln". Internal so the export dialog + /// can show the same name as its watermark instead of predicting it. + /// + internal static string SolutionFileNameFor(string outputDirectory) { var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; diff --git a/ILSpy/Views/ExportProjectDialog.axaml b/ILSpy/Views/ExportProjectDialog.axaml index ab360dd341..a467f4388a 100644 --- a/ILSpy/Views/ExportProjectDialog.axaml +++ b/ILSpy/Views/ExportProjectDialog.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:views="clr-namespace:ICSharpCode.ILSpy.Views" x:Class="ICSharpCode.ILSpy.Views.ExportProjectDialog" - Width="560" Height="580" MinWidth="460" MinHeight="440" + Width="560" MinWidth="460" MinHeight="440" MaxHeight="900" + SizeToContent="Height" WindowStartupLocation="CenterOwner" Title="Export Project"> @@ -11,7 +12,7 @@