diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
index 4bd486e92..89fc54866 100644
--- a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
+++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
@@ -31,6 +31,7 @@ using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.ViewModels;
+using ICSharpCode.ILSpyX.TreeView;
using NUnit.Framework;
@@ -132,6 +133,37 @@ public class SaveCodeProjectExportTests
}
}
+ [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 91e7b2d0f..ca16e2483 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 class FixtureAssembly
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 42652d78c..0f307357d 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 @@ using NUnit.Framework;
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 class ExportPreviewRowTests
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 c135c614b..a4ab7d6ba 100644
--- a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
+++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
@@ -134,6 +134,116 @@ public class ProjectExportRunnerTests
}
}
+ [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 d27906ce9..c8ba8ea0e 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 @@ namespace ICSharpCode.ILSpy.Commands
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 @@ namespace ICSharpCode.ILSpy.Commands
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 bdff0f6c1..ddff5bdf7 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 @@ namespace ICSharpCode.ILSpy.Commands
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 @@ namespace ICSharpCode.ILSpy.Commands
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 ab360dd34..a467f4388 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 02a9af523..6efee2bb0 100644
--- a/ILSpy/Views/ExportProjectDialog.axaml.cs
+++ b/ILSpy/Views/ExportProjectDialog.axaml.cs
@@ -23,6 +23,7 @@ using Avalonia.Controls;
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 @@ namespace ICSharpCode.ILSpy.Views
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 @@ namespace ICSharpCode.ILSpy.Views
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 @@ namespace ICSharpCode.ILSpy.Views
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;