From 150fa30aa34cc798b87e2848a965324d7189a572 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 15 Jul 2026 22:55:30 +0200 Subject: [PATCH] Export what loaded, and let the user name the solution Two gaps in the export paths, both visible from the same selection. A selection holding an assembly that failed to load was turned away by TryGetExportableAssemblies, so Ctrl+S fell through to the single-node save and quietly wrote just the focused assembly -- the rest of the selection vanished with no report. The predicate now only insists that something in the selection loaded, and the exporter skips what it cannot decompile and names it in the status report. That is also what the dialog always assumed: its "not a valid assembly" row badge was unreachable, because no selection containing one could get that far. The dialog asks for an output folder and derived the .sln name from it, while Save Code lets the user name the file. Now the dialog offers the name too, in solution mode, defaulting (via the placeholder) to the folder- derived name the exporter would pick anyway. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Commands/SaveCodeProjectExportTests.cs | 32 +++++ ILSpy.Tests/FixtureAssembly.cs | 75 +++++++++++ .../Languages/ExportPreviewRowTests.cs | 124 +++++++++++++++++- .../Languages/ProjectExportRunnerTests.cs | 110 ++++++++++++++++ ILSpy/Commands/ProjectExport.cs | 38 +++++- ILSpy/Commands/ProjectExporter.cs | 77 +++++++++-- ILSpy/Views/ExportProjectDialog.axaml | 10 +- ILSpy/Views/ExportProjectDialog.axaml.cs | 39 +++++- 8 files changed, 484 insertions(+), 21 deletions(-) diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs index 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 @@