Browse Source

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
pull/3885/head
Siegfried Pammer 2 months ago committed by Siegfried Pammer
parent
commit
150fa30aa3
  1. 32
      ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
  2. 75
      ILSpy.Tests/FixtureAssembly.cs
  3. 124
      ILSpy.Tests/Languages/ExportPreviewRowTests.cs
  4. 110
      ILSpy.Tests/Languages/ProjectExportRunnerTests.cs
  5. 38
      ILSpy/Commands/ProjectExport.cs
  6. 77
      ILSpy/Commands/ProjectExporter.cs
  7. 10
      ILSpy/Views/ExportProjectDialog.axaml
  8. 39
      ILSpy/Views/ExportProjectDialog.axaml.cs

32
ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs

@ -31,6 +31,7 @@ using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpy.ViewModels;
using ICSharpCode.ILSpyX.TreeView;
using NUnit.Framework; 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] [AvaloniaTest]
public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab() public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab()
{ {

75
ILSpy.Tests/FixtureAssembly.cs

@ -17,9 +17,13 @@
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System; using System;
using System.Collections.Immutable;
using System.IO; using System.IO;
using System.Linq;
using System.Reflection; using System.Reflection;
using System.Reflection.Emit; using System.Reflection.Emit;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Threading.Tasks; using System.Threading.Tasks;
using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX;
@ -93,4 +97,75 @@ public static class FixtureAssembly
ArgumentNullException.ThrowIfNull(vm); ArgumentNullException.ThrowIfNull(vm);
return vm.OpenAssemblyAsync(Emit(name)); return vm.OpenAssemblyAsync(Emit(name));
} }
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static async Task<LoadedAssembly> 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;
}
/// <summary>
/// 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
/// <see cref="TestHarness.OpenAssemblyAsync"/> this waits on <see cref="LoadedAssembly.HasLoadError"/>
/// rather than the load result, which for such a file rethrows the load failure.
/// </summary>
public static async Task<LoadedAssembly> 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;
}
} }

124
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 // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Headless.NUnit; using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AwesomeAssertions; using AwesomeAssertions;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Commands;
using ICSharpCode.ILSpy.Views; using ICSharpCode.ILSpy.Views;
using NUnit.Framework; using NUnit.Framework;
@ -30,9 +37,10 @@ using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Languages; namespace ICSharpCode.ILSpy.Tests.Languages;
/// <summary> /// <summary>
/// The Export Project/Solution dialog's preview computation (extracted as a pure static so it is /// The Export Project/Solution dialog's choices, extracted as pure statics so they are testable
/// testable without the window): per-assembly project name, target subdirectory, and the /// without the window: the preview rows (per-assembly project name, target subdirectory, and the
/// invalid / duplicate-name / PDB-eligible badges. /// invalid / duplicate-name / PDB-eligible badges) and the solution file name the user may type in
/// solution mode.
/// </summary> /// </summary>
[TestFixture] [TestFixture]
public class ExportPreviewRowTests public class ExportPreviewRowTests
@ -82,4 +90,114 @@ public class ExportPreviewRowTests
rows.Should().OnlyContain(r => r.HasDuplicateShortName); rows.Should().OnlyContain(r => r.HasDuplicateShortName);
rows.Should().OnlyContain(r => r.BadgeText.Contains("duplicate name")); 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<SettingsService>();
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<SettingsService>();
// 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");
}
} }

110
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] [AvaloniaTest]
public async Task StrongNameKeyFile_Is_Copied_Into_Project() public async Task StrongNameKeyFile_Is_Copied_Into_Project()
{ {

38
ILSpy/Commands/ProjectExport.cs

@ -57,8 +57,11 @@ namespace ICSharpCode.ILSpy.Commands
internal static class ProjectExport internal static class ProjectExport
{ {
/// <summary> /// <summary>
/// True when <paramref name="nodes"/> is one or more assembly nodes that all loaded as valid /// True when <paramref name="nodes"/> is one or more assembly nodes, at least one of which loaded
/// assemblies. <paramref name="solutionMode"/> is set when more than one is selected. /// as a valid assembly. Ones that failed to load stay in <paramref name="assemblies"/>: the dialog
/// badges them and <see cref="ProjectExporter"/> skips them with a line in the report, so a mixed
/// selection exports what it can instead of being turned away here.
/// <paramref name="solutionMode"/> is set when more than one node is selected.
/// </summary> /// </summary>
public static bool TryGetExportableAssemblies(IReadOnlyList<SharpTreeNode>? nodes, public static bool TryGetExportableAssemblies(IReadOnlyList<SharpTreeNode>? nodes,
out List<LoadedAssembly> assemblies, out bool solutionMode) out List<LoadedAssembly> assemblies, out bool solutionMode)
@ -67,21 +70,38 @@ namespace ICSharpCode.ILSpy.Commands
solutionMode = false; solutionMode = false;
if (nodes is not { Count: > 0 }) if (nodes is not { Count: > 0 })
return false; return false;
if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) if (!nodes.All(n => n is AssemblyTreeNode))
return false; return false;
assemblies = nodes.OfType<AssemblyTreeNode>().Select(n => n.LoadedAssembly).ToList();
solutionMode = assemblies.Count > 1; var selected = nodes.OfType<AssemblyTreeNode>().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; return true;
} }
/// <summary> /// <summary>
/// True when <paramref name="nodes"/> is the selection shape Save Code maps onto a solution: /// True when <paramref name="nodes"/> 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 <see cref="TryGetExportableAssemblies"/> sets out -- the
/// ones that did not load ride along in <paramref name="assemblies"/> to be skipped and reported
/// by the exporter, rather than costing the whole selection its solution export.
/// </summary> /// </summary>
public static bool TryGetSolutionAssemblies(IReadOnlyList<SharpTreeNode>? nodes, public static bool TryGetSolutionAssemblies(IReadOnlyList<SharpTreeNode>? nodes,
out List<LoadedAssembly> assemblies) out List<LoadedAssembly> assemblies)
=> TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode; => TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode;
/// <summary>
/// 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.
/// </summary>
internal static Task EnsureAssembliesLoadedAsync(IReadOnlyList<LoadedAssembly> assemblies)
=> Task.WhenAll(assemblies.Select(a => a.GetMetadataFileOrNullAsync()));
public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> assemblies, public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> assemblies,
bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService) bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService)
{ {
@ -89,6 +109,12 @@ namespace ICSharpCode.ILSpy.Commands
if (owner == null) if (owner == null)
return; 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 dialog = new ExportProjectDialog(settingsService, assemblies, solutionMode);
var options = await dialog.ShowDialog<ProjectExportOptions?>(owner).ConfigureAwait(true); var options = await dialog.ShowDialog<ProjectExportOptions?>(owner).ConfigureAwait(true);
if (options is null || string.IsNullOrEmpty(options.OutputDirectory)) if (options is null || string.IsNullOrEmpty(options.OutputDirectory))

77
ILSpy/Commands/ProjectExporter.cs

@ -19,6 +19,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -37,9 +38,10 @@ namespace ICSharpCode.ILSpy.Commands
/// <summary> /// <summary>
/// The UI-agnostic engine behind the Export Project/Solution dialog. Unifies single-assembly /// The UI-agnostic engine behind the Export Project/Solution dialog. Unifies single-assembly
/// project export and multi-assembly solution export (the latter via <see cref="SolutionWriter"/>), /// project export and multi-assembly solution export (the latter via <see cref="SolutionWriter"/>),
/// applies the dialog's overrides onto a settings clone, and optionally emits a portable PDB per /// applies the dialog's overrides onto a settings clone, skips (and reports) the entries with no code
/// assembly. Returns a <see cref="SolutionExportResult"/> the caller surfaces in the text view. /// behind them, and optionally emits a portable PDB per assembly. Returns a
/// Kept separate from the dialog so it is headless-testable. /// <see cref="SolutionExportResult"/> the caller surfaces in the text view. Kept separate from the
/// dialog so it is headless-testable.
/// </summary> /// </summary>
internal static class ProjectExporter internal static class ProjectExporter
{ {
@ -55,26 +57,80 @@ namespace ICSharpCode.ILSpy.Commands
ApplyOverrides(settingsClone, options); 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) if (solutionMode)
{ {
var solutionFilePath = Path.Combine(options.OutputDirectory, var solutionFilePath = Path.Combine(options.OutputDirectory,
options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory)); options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory));
var solution = await SolutionWriter.CreateSolutionAsync( var solution = await SolutionWriter.CreateSolutionAsync(
solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile, progress) solutionFilePath, language, exportable, ct, settingsClone, options.StrongNameKeyFile, progress)
.ConfigureAwait(false); .ConfigureAwait(false);
var report = new StringBuilder(solution.StatusText); var report = new StringBuilder(solution.StatusText);
if (options.GeneratePdb && solution.Success) 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) a => Path.Combine(options.OutputDirectory, a.ShortName), settingsClone, options, report, ct), ct)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
AppendSkipReport(report, skipReport);
return new SolutionExportResult(solution.Success, report.ToString()); 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); .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, static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options,
@ -160,9 +216,12 @@ namespace ICSharpCode.ILSpy.Commands
settings.UseDebugSymbols = options.UseDebugSymbols; settings.UseDebugSymbols = options.UseDebugSymbols;
} }
// The default .sln name when the caller does not supply one: after the chosen output folder, /// <summary>
// falling back to "Solution.sln". /// The <c>.sln</c> name used when <see cref="ProjectExportOptions.SolutionFileName"/> is unset:
static string SolutionFileNameFor(string outputDirectory) /// 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.
/// </summary>
internal static string SolutionFileNameFor(string outputDirectory)
{ {
var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln";

10
ILSpy/Views/ExportProjectDialog.axaml

@ -2,7 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:ICSharpCode.ILSpy.Views" xmlns:views="clr-namespace:ICSharpCode.ILSpy.Views"
x:Class="ICSharpCode.ILSpy.Views.ExportProjectDialog" 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" WindowStartupLocation="CenterOwner"
Title="Export Project"> Title="Export Project">
<DockPanel Margin="12"> <DockPanel Margin="12">
@ -11,7 +12,7 @@
<Button Name="ExportButton" Content="Export" IsDefault="True" MinWidth="80" /> <Button Name="ExportButton" Content="Export" IsDefault="True" MinWidth="80" />
<Button Name="CancelButton" Content="Cancel" IsCancel="True" MinWidth="80" /> <Button Name="CancelButton" Content="Cancel" IsCancel="True" MinWidth="80" />
</StackPanel> </StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Disabled"> <ScrollViewer Name="OptionsScroll" HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Text="Output location:" /> <TextBlock Text="Output location:" />
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
@ -19,6 +20,11 @@
<Button Grid.Column="1" Name="BrowseOutputButton" Content="Browse..." Margin="6,0,0,0" /> <Button Grid.Column="1" Name="BrowseOutputButton" Content="Browse..." Margin="6,0,0,0" />
</Grid> </Grid>
<StackPanel Name="SolutionNamePanel" Spacing="6" IsVisible="False">
<TextBlock Text="Solution file name:" Margin="0,6,0,0" />
<TextBox Name="SolutionNameBox" />
</StackPanel>
<TextBlock Text="Projects to export:" Margin="0,6,0,0" /> <TextBlock Text="Projects to export:" Margin="0,6,0,0" />
<Border BorderBrush="#80808080" BorderThickness="1" Height="150"> <Border BorderBrush="#80808080" BorderThickness="1" Height="150">
<ListBox Name="PreviewList"> <ListBox Name="PreviewList">

39
ILSpy/Views/ExportProjectDialog.axaml.cs

@ -23,6 +23,7 @@ using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.DebugInfo; using ICSharpCode.Decompiler.DebugInfo;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX;
@ -99,11 +100,18 @@ namespace ICSharpCode.ILSpy.Views
UseDebugSymbolsCheck.IsChecked = settings.UseDebugSymbols; 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 (_, _) => { BrowseOutputButton.Click += async (_, _) => {
var folder = await FilePickers.PickFolderAsync("Select the export output folder"); var folder = await FilePickers.PickFolderAsync("Select the export output folder");
if (!string.IsNullOrEmpty(folder)) if (!string.IsNullOrEmpty(folder))
{ {
OutputBox.Text = folder; OutputBox.Text = folder;
UpdateSolutionNameWatermark();
UpdateExportEnabled(); UpdateExportEnabled();
} }
}; };
@ -161,6 +169,29 @@ namespace ICSharpCode.ILSpy.Views
return rows; return rows;
} }
/// <summary>
/// The <c>.sln</c> file name to export under, from what the user typed: <c>null</c> when the text
/// names nothing beyond the extension, so that <see cref="ProjectExporter"/> names it after the
/// output folder as it always has; otherwise the typed text reduced to a bare, valid file name
/// carrying a single <c>.sln</c> extension. Pure and side-effect free so it can be unit-tested
/// without the window.
/// </summary>
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() ProjectExportOptions BuildOptions()
=> new( => new(
OutputBox.Text ?? string.Empty, OutputBox.Text ?? string.Empty,
@ -171,7 +202,13 @@ namespace ICSharpCode.ILSpy.Views
UseDebugSymbolsCheck.IsChecked == true, UseDebugSymbolsCheck.IsChecked == true,
string.IsNullOrEmpty(KeyFileBox.Text) ? null : KeyFileBox.Text, string.IsNullOrEmpty(KeyFileBox.Text) ? null : KeyFileBox.Text,
GeneratePdbCheck.IsChecked == true, 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() void UpdateExportEnabled()
=> ExportButton.IsEnabled = !string.IsNullOrEmpty(OutputBox.Text) && !hasDuplicateConflict; => ExportButton.IsEnabled = !string.IsNullOrEmpty(OutputBox.Text) && !hasDuplicateConflict;

Loading…
Cancel
Save