diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
new file mode 100644
index 0000000000..89fc548660
--- /dev/null
+++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
@@ -0,0 +1,202 @@
+// 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 ICSharpCode.ILSpyX.TreeView;
+
+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, 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
+{
+ [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_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 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()
+ {
+ 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.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..942a970e9f 100644
--- a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
+++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
@@ -134,6 +134,177 @@ public async Task Solution_Mode_Writes_Sln_And_Projects()
}
}
+ [AvaloniaTest]
+ public async Task Solution_Progress_Is_Determinate_And_Counts_Files_Across_Projects()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var assemblies = await OpenFixtures(vm, 2);
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnProgress_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var progress = new RecordingProgress();
+ var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir),
+ new DecompilerSettings(), Language(), progress, CancellationToken.None);
+ result.Success.Should().BeTrue(result.StatusText);
+
+ progress.Reports.Should().NotBeEmpty();
+ progress.Reports.Max(p => p.TotalUnits).Should().BeGreaterThan(assemblies.Count,
+ "the bar counts the files of every project put together, not whole assemblies -- an assembly-granular "
+ + "bar only moves when a project finishes, which for a solution of two is 0%, 50%, done");
+ progress.Reports.Should().Contain(p => p.TotalUnits > 0 && p.UnitsCompleted < p.TotalUnits,
+ "a determinate report has to arrive while work is still outstanding; reporting only on completion "
+ + "leaves the tab showing an indeterminate spinner for the whole export");
+ progress.Reports.Should().Contain(p => p.Status != null && p.Status.Contains(assemblies[0].ShortName),
+ "the status names the projects being written");
+ }
+ finally
+ {
+ TryDelete(tempDir);
+ }
+ }
+
+ [AvaloniaTest]
+ public async Task Solution_Progress_Completes_Even_When_A_Project_Cannot_Be_Written()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var assemblies = await OpenFixtures(vm, 2);
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnStuck_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ // A file where the second project's directory needs to go: that project bails out before it
+ // writes anything, which used to leave its share of the bar outstanding forever.
+ await File.WriteAllTextAsync(Path.Combine(tempDir, assemblies[1].ShortName), "in the way");
+
+ var progress = new RecordingProgress();
+ var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir),
+ new DecompilerSettings(), Language(), progress, CancellationToken.None);
+
+ result.Success.Should().BeFalse("a project that cannot be written is a failed export");
+ progress.Reports.Should().NotBeEmpty();
+ var last = progress.Reports[^1];
+ last.UnitsCompleted.Should().Be(last.TotalUnits,
+ "the bar has to close out when the export stops, even though one project never ran");
+ }
+ finally
+ {
+ TryDelete(tempDir);
+ }
+ }
+
+ [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/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 dd8d6e060f..c8ba8ea0e4 100644
--- a/ILSpy/Commands/ProjectExport.cs
+++ b/ILSpy/Commands/ProjectExport.cs
@@ -25,6 +25,8 @@
using global::Avalonia.Controls.ApplicationLifetimes;
+using ICSharpCode.Decompiler;
+using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
@@ -39,16 +41,27 @@
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
{
///
- /// 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)
@@ -57,13 +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, 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)
{
@@ -71,17 +109,106 @@ 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))
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 .
+ /// 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)
+ {
+ ArgumentNullException.ThrowIfNull(assembly);
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(dockWorkspace);
+
+ 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,
+ RemoveDeadCode: settings.RemoveDeadCode,
+ RemoveDeadStores: settings.RemoveDeadStores,
+ UseDebugSymbols: settings.UseDebugSymbols,
+ StrongNameKeyFile: null,
+ GeneratePdb: false,
+ 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. 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))
+ : 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/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..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,25 +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, 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)
+ 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,
@@ -159,8 +216,12 @@ 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 .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/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/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/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);
- }
-
- }
-}
diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs
index 81c931fdee..3ebe1f2e43 100644
--- a/ILSpy/SolutionWriter.cs
+++ b/ILSpy/SolutionWriter.cs
@@ -82,7 +82,14 @@ public static Task CreateSolutionAsync(string solutionFile
readonly IProgress? progress;
readonly ConcurrentBag projects;
readonly ConcurrentBag statusOutput;
- int completedAssemblies;
+
+ // How far each project has got, keyed by assembly short name -- unique, because duplicate names
+ // abort the export before any project runs. The workers fill these in as they decompile and the
+ // progress bar shows their sum.
+ readonly ConcurrentDictionary projectProgress;
+ // The projects in selection order, so the status label lists them in a stable order rather than
+ // in whatever order the workers happen to reach them.
+ string[] projectOrder;
SolutionWriter(string solutionFilePath, DecompilerSettings settings, string? strongNameKeyFile,
IProgress? progress)
@@ -94,6 +101,32 @@ public static Task CreateSolutionAsync(string solutionFile
solutionDirectory = Path.GetDirectoryName(solutionFilePath)!;
statusOutput = new ConcurrentBag();
projects = new ConcurrentBag();
+ projectProgress = new ConcurrentDictionary();
+ projectOrder = Array.Empty();
+ }
+
+ /// How much of one project's file list has been written, and whether it is still running.
+ sealed class ProjectProgress
+ {
+ public int FilesWritten;
+ public int FileCount;
+ public bool Running;
+ }
+
+ ///
+ /// Feeds one project's file counts into the shared total.
+ /// reports its whole file count with every report, so the solution bar knows a project's size from
+ /// its first written file rather than only once the project is done.
+ ///
+ sealed class ProjectProgressReporter(SolutionWriter writer, ProjectProgress project)
+ : IProgress
+ {
+ public void Report(DecompilationProgress value)
+ {
+ project.FileCount = value.TotalUnits;
+ project.FilesWritten = value.UnitsCompleted;
+ writer.ReportProgress();
+ }
}
async Task CreateSolutionAsync(IReadOnlyList allAssemblies,
@@ -123,13 +156,15 @@ async Task CreateSolutionAsync(IReadOnlyList a.ShortName).ToArray();
+
try
{
// An explicit enumerable partitioner avoids Parallel.ForEach's list special-casing,
// whose static partitioning is inefficient when assemblies decompile at different speeds.
await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies),
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct },
- item => WriteProject(item, language, solutionDirectory, allAssemblies.Count, ct)))
+ item => WriteProject(item, language, solutionDirectory, ct)))
.ConfigureAwait(false);
if (projects.Count == 0)
@@ -183,15 +218,68 @@ await Task.Run(() => SolutionCreator.WriteSolutionFile(solutionFilePath, project
return new SolutionExportResult(true, report.ToString());
}
- void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, int totalAssemblies, CancellationToken ct)
+ // Reports the whole solution's progress: the file counts of every project added up. The projects
+ // are decompiled in parallel, so no single one of them can drive the bar; summing them lets it
+ // move continuously and, because a project reports its file count as soon as it writes its first
+ // file, turn determinate right after the export starts. Racing reads are fine here -- the worst a
+ // report that races a worker can be is a file or two out of date.
+ void ReportProgress()
{
- // Solution export decompiles assemblies in parallel, so per-file progress would race; report
- // at the coarser assembly granularity instead -- a determinate bar over the assembly count.
- void ReportDone() => progress?.Report(new DecompilationProgress {
- TotalUnits = totalAssemblies,
- UnitsCompleted = System.Threading.Interlocked.Increment(ref completedAssemblies),
- Status = loadedAssembly.ShortName,
+ if (progress == null)
+ return;
+
+ int filesWritten = 0, fileCount = 0;
+ foreach (var project in projectProgress.Values)
+ {
+ filesWritten += project.FilesWritten;
+ fileCount += project.FileCount;
+ }
+
+ progress.Report(new DecompilationProgress {
+ TotalUnits = fileCount,
+ UnitsCompleted = filesWritten,
+ Title = "Exporting solution...",
+ Status = RunningProjects(),
});
+ }
+
+ // The projects being written right now, so the label says what is running instead of naming
+ // whichever project happened to report last. Long selections are cut short: the bar is not the
+ // place to list twenty assemblies.
+ string RunningProjects()
+ {
+ const int maxNames = 3;
+ var running = projectOrder
+ .Where(name => projectProgress.TryGetValue(name, out var project) && project.Running)
+ .ToList();
+ return running.Count <= maxNames
+ ? string.Join(", ", running)
+ : string.Join(", ", running.Take(maxNames)) + $" and {running.Count - maxNames} more";
+ }
+
+ void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct)
+ {
+ var project = new ProjectProgress { Running = true };
+ projectProgress[loadedAssembly.ShortName] = project;
+ ReportProgress();
+ try
+ {
+ WriteProjectCore(loadedAssembly, language, targetDirectory, project, ct);
+ }
+ finally
+ {
+ // Whatever became of the project -- written, bailed out before it started, or cancelled --
+ // it stops counting against the total here. Leaving an abandoned project's files
+ // outstanding would strand the bar short of the end for the rest of the export.
+ project.FilesWritten = project.FileCount;
+ project.Running = false;
+ ReportProgress();
+ }
+ }
+
+ void WriteProjectCore(LoadedAssembly loadedAssembly, Language language, string targetDirectory,
+ ProjectProgress project, CancellationToken ct)
+ {
targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName);
if (language.ProjectFileExtension == null)
@@ -230,6 +318,7 @@ void ReportDone() => progress?.Report(new DecompilationProgress {
options.CancellationToken = ct;
options.SaveAsProjectDirectory = targetDirectory;
options.StrongNameKeyFile = strongNameKeyFile;
+ options.ProgressIndicator = new ProjectProgressReporter(this, project);
// The project-export path writes the .csproj into SaveAsProjectDirectory itself; the
// ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here.
@@ -260,7 +349,6 @@ void ReportDone() => progress?.Report(new DecompilationProgress {
statusOutput.Add("-------------");
statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}");
}
- ReportDone();
}
}
}
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,
};
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 @@
-
+
@@ -19,6 +20,11 @@
+
+
+
+
+
diff --git a/ILSpy/Views/ExportProjectDialog.axaml.cs b/ILSpy/Views/ExportProjectDialog.axaml.cs
index 02a9af5236..6efee2bb08 100644
--- a/ILSpy/Views/ExportProjectDialog.axaml.cs
+++ b/ILSpy/Views/ExportProjectDialog.axaml.cs
@@ -23,6 +23,7 @@
using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage;
+using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.DebugInfo;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX;
@@ -99,11 +100,18 @@ public ExportProjectDialog(SettingsService? settingsService,
UseDebugSymbolsCheck.IsChecked = settings.UseDebugSymbols;
}
+ // Naming the .sln is only a solution-mode question; a single project takes its name from the
+ // assembly. Left blank, the export falls back to naming it after the output folder, which the
+ // watermark spells out as the folder is picked.
+ SolutionNamePanel.IsVisible = solutionMode;
+ UpdateSolutionNameWatermark();
+
BrowseOutputButton.Click += async (_, _) => {
var folder = await FilePickers.PickFolderAsync("Select the export output folder");
if (!string.IsNullOrEmpty(folder))
{
OutputBox.Text = folder;
+ UpdateSolutionNameWatermark();
UpdateExportEnabled();
}
};
@@ -161,6 +169,29 @@ internal static IReadOnlyList BuildPreviewRows(
return rows;
}
+ ///
+ /// The .sln file name to export under, from what the user typed: null when the text
+ /// names nothing beyond the extension, so that names it after the
+ /// output folder as it always has; otherwise the typed text reduced to a bare, valid file name
+ /// carrying a single .sln extension. Pure and side-effect free so it can be unit-tested
+ /// without the window.
+ ///
+ internal static string? NormalizeSolutionFileName(string? typedName)
+ {
+ var name = typedName?.Trim();
+ if (string.IsNullOrEmpty(name))
+ return null;
+ // CleanUpFileName appends the extension itself, so drop one the user already typed rather
+ // than ending up with "Solution.sln.sln".
+ if (name.EndsWith(".sln", System.StringComparison.OrdinalIgnoreCase))
+ name = name[..^4];
+ // Nothing but the extension is as blank as an empty box and defers the same way: an empty name
+ // reaching CleanUpFileName comes back as "-", which would export "-.sln".
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+ return WholeProjectDecompiler.CleanUpFileName(name, ".sln");
+ }
+
ProjectExportOptions BuildOptions()
=> new(
OutputBox.Text ?? string.Empty,
@@ -171,7 +202,13 @@ ProjectExportOptions BuildOptions()
UseDebugSymbolsCheck.IsChecked == true,
string.IsNullOrEmpty(KeyFileBox.Text) ? null : KeyFileBox.Text,
GeneratePdbCheck.IsChecked == true,
- EmbedSourceCheck.IsChecked == true);
+ EmbedSourceCheck.IsChecked == true,
+ solutionMode ? NormalizeSolutionFileName(SolutionNameBox.Text) : null);
+
+ // Show the name the export would pick for the current output folder, so leaving the box empty is
+ // an informed choice rather than a blank.
+ void UpdateSolutionNameWatermark()
+ => SolutionNameBox.PlaceholderText = ProjectExporter.SolutionFileNameFor(OutputBox.Text ?? string.Empty);
void UpdateExportEnabled()
=> ExportButton.IsEnabled = !string.IsNullOrEmpty(OutputBox.Text) && !hasDuplicateConflict;