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 @@