diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs new file mode 100644 index 0000000000..89fc548660 --- /dev/null +++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs @@ -0,0 +1,202 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; +using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpyX.TreeView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Commands; + +/// +/// Every File -> Save Code path for an assembly runs behind a cancellable progress overlay, never a +/// bare Task.Run, and every one that decompiles a whole assembly shares the Export Project command's +/// frozen determinate-progress tab: for a +/// .csproj, for several assemblies as a .sln. The +/// single-file save goes through (the +/// same overlay normal decompilation uses). These assert each path writes its output and surfaces a +/// report/breadcrumb in a tab. +/// +[TestFixture] +public class SaveCodeProjectExportTests +{ + [AvaloniaTest] + public async Task Save_As_Project_Runs_Through_The_Export_Project_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var assembly = await vm.OpenFixtureAsync(); + var dock = vm.DockWorkspace; + + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveProj_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + await ProjectExport.ExportSingleAssemblyAsync( + assembly, tempDir, settings, language, dock); + + Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1, + "Save Code -> .csproj must run the whole-project decompiler, exactly like Export Project"); + Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(); + + var exportTab = dock.Documents!.VisibleDockables!.OfType() + .Select(t => t.Content).OfType() + .FirstOrDefault(d => d.Text != null && d.Text.Contains("Project written to")); + exportTab.Should().NotBeNull("the export runs in its own progress tab and shows the project-written report there"); + exportTab!.Title.Should().Be(assembly.Text, + "a single-assembly export tab is titled after that assembly's tree-node label -- the same however it was started (Save Code or Export Project)"); + } + finally + { + try + { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort */ } + } + } + + [AvaloniaTest] + public async Task Save_Several_Assemblies_Exports_A_Solution_Through_The_Export_Project_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var assemblies = new[] { + await vm.OpenFixtureAsync("FixtureA"), + await vm.OpenFixtureAsync("FixtureB"), + }; + var dock = vm.DockWorkspace; + + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveSln_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + // A name the output folder does not imply: Save Code lets the user pick the .sln file itself, + // unlike the Export Project dialog, which derives the name from the chosen folder. + var solutionPath = Path.Combine(tempDir, "Picked.sln"); + try + { + await ProjectExport.ExportSolutionAsync(assemblies, solutionPath, settings, language, dock); + + File.Exists(solutionPath).Should().BeTrue( + "the solution must land on the exact path picked in Save Code, not one derived from the folder"); + foreach (var a in assemblies) + { + Directory.EnumerateFiles(Path.Combine(tempDir, a.ShortName), "*.csproj").Should().HaveCount(1, + $"each exported assembly gets its own project ({a.ShortName})"); + } + + var exportTab = dock.Documents!.VisibleDockables!.OfType() + .Select(t => t.Content).OfType() + .FirstOrDefault(d => d.Title == string.Join(", ", assemblies.Select(a => a.Text))); + exportTab.Should().NotBeNull( + "a solution export tab is titled after the assemblies being exported -- the same however it was started (Save Code or Export Project)"); + exportTab!.Text.Should().Contain("Created the Visual Studio Solution file", + "the export runs in its own progress tab and reports there"); + } + finally + { + try + { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort */ } + } + } + + [AvaloniaTest] + public async Task A_Failed_Load_In_The_Selection_Does_Not_Disqualify_The_Solution_Export() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var broken = await vm.OpenBrokenFixtureAsync(); + + // The selection Ctrl+S sees: two assembly nodes, one of which failed to load. Rejecting it + // here is what used to make Save Code fall through and quietly save the focused assembly + // alone; the exporter skips the unloadable one and reports it instead. + var nodes = new SharpTreeNode[] { new AssemblyTreeNode(good), new AssemblyTreeNode(broken) }; + + ProjectExport.TryGetSolutionAssemblies(nodes, out var assemblies).Should().BeTrue( + "an assembly that failed to load must not disqualify the whole selection"); + assemblies.Should().BeEquivalentTo([good, broken], + "the exporter decides what to skip, so it has to see the failed load to report it"); + } + + [AvaloniaTest] + public async Task A_Selection_Where_Nothing_Loaded_Has_Nothing_To_Export() + { + var (_, vm) = await TestHarness.BootAsync(); + var broken = await vm.OpenBrokenFixtureAsync("BrokenA"); + var alsoBroken = await vm.OpenBrokenFixtureAsync("BrokenB"); + + var nodes = new SharpTreeNode[] { new AssemblyTreeNode(broken), new AssemblyTreeNode(alsoBroken) }; + + ProjectExport.TryGetSolutionAssemblies(nodes, out _).Should().BeFalse( + "a solution of nothing but failed loads would be empty; Save Code leaves the selection to the single-node path"); + } + + [AvaloniaTest] + public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab() + { + var (_, vm) = await TestHarness.BootAsync(); + var assembly = await vm.OpenFixtureAsync(); + var dock = vm.DockWorkspace; + + var assemblyNode = vm.AssemblyTreeModel.FindNode(assembly.ShortName); + Assert.That(assemblyNode, Is.Not.Null); + // Select the assembly so there is an active decompiler tab for RunWithCancellation to run on. + vm.AssemblyTreeModel.SelectNode(assemblyNode); + await dock.WaitForDecompiledTextAsync(); + + var language = AppComposition.Current.GetExport() + .Languages.OfType().First(); + + var path = Path.Combine(Path.GetTempPath(), "ILSpySaveSingle_" + System.Guid.NewGuid().ToString("N") + ".cs"); + try + { + await SaveCodeHelper.SaveNodeToFileWithProgressAsync(assemblyNode!, language, path, dock); + + File.Exists(path).Should().BeTrue("the single-file save must write the assembly to disk"); + (await File.ReadAllTextAsync(path)).Should().Contain(FixtureAssembly.TypeName, + "the decompiled assembly's type must be present"); + + dock.Documents!.VisibleDockables!.OfType() + .Any(t => t.Content is DecompilerTabPageModel d && d.Text != null && d.Text.Contains("Decompilation complete")) + .Should().BeTrue("the single-file save runs behind the cancellable overlay and shows a completion breadcrumb"); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } +} diff --git a/ILSpy.Tests/FixtureAssembly.cs b/ILSpy.Tests/FixtureAssembly.cs index 91e7b2d0f5..ca16e24833 100644 --- a/ILSpy.Tests/FixtureAssembly.cs +++ b/ILSpy.Tests/FixtureAssembly.cs @@ -17,9 +17,13 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; using System.Threading.Tasks; using ICSharpCode.ILSpyX; @@ -93,4 +97,75 @@ public static Task OpenFixtureAsync(this MainWindowViewModel vm, ArgumentNullException.ThrowIfNull(vm); return vm.OpenAssemblyAsync(Emit(name)); } + + /// + /// Writes a standalone portable PDB to a temp file and returns its path. It carries a metadata + /// root but no PE image, so it loads as a metadata-only file -- the "(Debug Metadata)" entry + /// ILSpy shows for a .pdb opened on its own. Callers use it for the selection member that loads + /// successfully yet holds nothing to decompile. + /// + public static string EmitStandalonePdb(string name) + { + // An all-zero row count set describes an empty type system, which is all this needs: the file + // only has to be readable as metadata, not to match any particular assembly. + var pdbBuilder = new PortablePdbBuilder( + new MetadataBuilder(), ImmutableArray.CreateRange(new int[MetadataTokens.TableCount]), default); + var blob = new BlobBuilder(); + pdbBuilder.Serialize(blob); + + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyPdbFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"{name}.pdb"); + File.WriteAllBytes(path, blob.ToArray()); + return path; + } + + /// + /// Opens a standalone portable PDB, returning it once it has loaded. It loads successfully but is + /// metadata-only, so the export paths have to tell it apart from both a real assembly and a file + /// that failed to load. + /// + public static async Task OpenMetadataOnlyFixtureAsync(this MainWindowViewModel vm, string name = "MetadataOnly") + { + ArgumentNullException.ThrowIfNull(vm); + + var path = EmitStandalonePdb(name); + vm.AssemblyTreeModel.OpenFiles([path]); + await Waiters.WaitForAsync( + () => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Any(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)), + description: $"metadata-only file '{path}' to appear in the active list"); + + var loaded = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .First(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)); + await loaded.GetLoadResultAsync(); + return loaded; + } + + /// + /// Opens a file that is not a PE image at all, returning it once the load has failed. Callers use + /// it to build the mixed selections the export paths have to cope with. Unlike + /// this waits on + /// rather than the load result, which for such a file rethrows the load failure. + /// + public static async Task OpenBrokenFixtureAsync(this MainWindowViewModel vm, string name = "Broken") + { + ArgumentNullException.ThrowIfNull(vm); + + var dir = Path.Combine(Path.GetTempPath(), $"ILSpyBrokenFixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, $"{name}.dll"); + await File.WriteAllTextAsync(path, "not a real PE file"); + + vm.AssemblyTreeModel.OpenFiles([path]); + await Waiters.WaitForAsync( + () => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Any(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)), + description: $"broken assembly '{path}' to appear in the active list"); + + var loaded = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .First(a => string.Equals(a.FileName, path, StringComparison.OrdinalIgnoreCase)); + await Waiters.WaitForAsync(() => loaded.HasLoadError, description: $"'{path}' to fail loading"); + return loaded; + } } diff --git a/ILSpy.Tests/Languages/ExportPreviewRowTests.cs b/ILSpy.Tests/Languages/ExportPreviewRowTests.cs index 42652d78c9..0f307357d3 100644 --- a/ILSpy.Tests/Languages/ExportPreviewRowTests.cs +++ b/ILSpy.Tests/Languages/ExportPreviewRowTests.cs @@ -16,13 +16,20 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia.Headless.NUnit; +using Avalonia.Threading; using AwesomeAssertions; +using ICSharpCode.ILSpyX; + +using ICSharpCode.ILSpy; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; using ICSharpCode.ILSpy.Views; using NUnit.Framework; @@ -30,9 +37,10 @@ namespace ICSharpCode.ILSpy.Tests.Languages; /// -/// The Export Project/Solution dialog's preview computation (extracted as a pure static so it is -/// testable without the window): per-assembly project name, target subdirectory, and the -/// invalid / duplicate-name / PDB-eligible badges. +/// The Export Project/Solution dialog's choices, extracted as pure statics so they are testable +/// without the window: the preview rows (per-assembly project name, target subdirectory, and the +/// invalid / duplicate-name / PDB-eligible badges) and the solution file name the user may type in +/// solution mode. /// [TestFixture] public class ExportPreviewRowTests @@ -82,4 +90,114 @@ public async Task Duplicate_Short_Names_Are_Flagged_On_Every_Affected_Row() rows.Should().OnlyContain(r => r.HasDuplicateShortName); rows.Should().OnlyContain(r => r.BadgeText.Contains("duplicate name")); } + + [AvaloniaTest] + public async Task An_Assembly_That_Failed_To_Load_Is_Badged_As_Invalid() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var broken = await vm.OpenBrokenFixtureAsync(); + + var rows = ExportProjectDialog.BuildPreviewRows([good, broken], solutionMode: true); + + rows.Should().HaveCount(2); + rows.Single(r => r.ProjectName.StartsWith(broken.ShortName)).BadgeText.Should().Contain("not a valid assembly", + "the dialog has to say which rows the export will skip"); + rows.Single(r => r.ProjectName.StartsWith(good.ShortName)).IsValidAssembly.Should().BeTrue(); + } + + [AvaloniaTest] + public async Task The_Export_Flow_Settles_Loads_Before_The_Dialog_Reads_Them() + { + var (_, vm) = await TestHarness.BootAsync(); + // A LoadedAssembly loads lazily -- the tree builds its entries en masse and only the first await + // starts the work -- so a selected-but-never-opened assembly reaches the export flow with its + // load untouched. Its preview-row badges are read off the load result, so the flow has to settle + // the load first; otherwise the dialog blocks the UI thread forcing one as it builds the rows. + var assembly = new LoadedAssembly(vm.AssemblyTreeModel.AssemblyList!, FixtureAssembly.Emit("FixturePreview")); + assembly.IsLoadedAsValidAssembly.Should().BeFalse("nothing has triggered the load yet"); + + await ProjectExport.EnsureAssembliesLoadedAsync([assembly]); + + assembly.IsLoadedAsValidAssembly.Should().BeTrue( + "the export flow settles every selected assembly's load up front"); + ExportProjectDialog.BuildPreviewRows([assembly], solutionMode: false) + .Single().IsValidAssembly.Should().BeTrue("the dialog now badges it from a completed load"); + } + + [AvaloniaTest] + public async Task Solution_Name_Field_Is_Offered_Only_In_Solution_Mode() + { + var (_, vm) = await TestHarness.BootAsync(); + var a = await vm.OpenFixtureAsync("FixtureA"); + var b = await vm.OpenFixtureAsync("FixtureB"); + var settings = AppComposition.Current.GetExport(); + + var solutionDialog = new ExportProjectDialog(settings, [a, b], solutionMode: true); + solutionDialog.Show(); + solutionDialog.SolutionNamePanel.IsVisible.Should().BeTrue( + "the .sln is the user's to name when several assemblies are exported"); + solutionDialog.Capture("solution-mode"); + solutionDialog.Close(); + + var projectDialog = new ExportProjectDialog(settings, [a], solutionMode: false); + projectDialog.Show(); + projectDialog.SolutionNamePanel.IsVisible.Should().BeFalse( + "a single project export writes no solution, so there is no name to ask for"); + projectDialog.Capture("project-mode"); + projectDialog.Close(); + } + + [AvaloniaTest] + public async Task Export_Dialog_Fits_Its_Content_Without_Scrolling() + { + var (_, vm) = await TestHarness.BootAsync(); + var a = await vm.OpenFixtureAsync("FixtureA"); + var b = await vm.OpenFixtureAsync("FixtureB"); + var settings = AppComposition.Current.GetExport(); + + // Solution mode is the tall case: it carries the solution-name field on top of everything + // project mode shows. + foreach (var (mode, assemblies) in new[] { + ("solution", new[] { a, b }), + ("project", new[] { a }), + }) + { + var dialog = new ExportProjectDialog(settings, assemblies, solutionMode: mode == "solution"); + dialog.Show(); + Dispatcher.UIThread.RunJobs(DispatcherPriority.Loaded); + + dialog.OptionsScroll.Extent.Height.Should().BeLessThanOrEqualTo(dialog.OptionsScroll.Viewport.Height + 0.5, + $"every option must be reachable without scrolling in {mode} mode " + + $"(content {dialog.OptionsScroll.Extent.Height}, viewport {dialog.OptionsScroll.Viewport.Height})"); + + dialog.Close(); + } + } + + [TestCase("MySolution", "MySolution.sln", TestName = "A typed name gains the .sln extension")] + [TestCase("MySolution.sln", "MySolution.sln", TestName = "An already-qualified name is not doubled up")] + [TestCase(" Spaced ", "Spaced.sln", TestName = "Surrounding whitespace is trimmed")] + [TestCase("", null, TestName = "A blank name defers to the folder-derived default")] + [TestCase(" ", null, TestName = "A whitespace-only name defers to the folder-derived default")] + [TestCase(null, null, TestName = "An unset name defers to the folder-derived default")] + // Stripping the extension the user typed leaves nothing behind, which CleanUpFileName would turn + // into "-", exporting "-.sln" for what reads as a request for the default. + [TestCase(".sln", null, TestName = "A bare extension defers to the folder-derived default")] + [TestCase(" .SLN ", null, TestName = "A padded bare extension defers to the folder-derived default")] + public void Typed_Solution_Names_Are_Normalized(string? typed, string? expected) + { + ExportProjectDialog.NormalizeSolutionFileName(typed).Should().Be(expected); + } + + [Test] + public void A_Typed_Solution_Name_Cannot_Escape_The_Output_Folder() + { + var normalized = ExportProjectDialog.NormalizeSolutionFileName("../../etc/passwd"); + + normalized.Should().NotBeNull(); + normalized.Should().EndWith(".sln"); + Path.GetFileName(normalized).Should().Be(normalized, + "the name is combined with the chosen output directory, so it must stay a bare file name"); + } } diff --git a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs index c135c614b8..942a970e9f 100644 --- a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs +++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs @@ -134,6 +134,177 @@ public async Task Solution_Mode_Writes_Sln_And_Projects() } } + [AvaloniaTest] + public async Task Solution_Progress_Is_Determinate_And_Counts_Files_Across_Projects() + { + var (_, vm) = await TestHarness.BootAsync(); + var assemblies = await OpenFixtures(vm, 2); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnProgress_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var progress = new RecordingProgress(); + var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir), + new DecompilerSettings(), Language(), progress, CancellationToken.None); + result.Success.Should().BeTrue(result.StatusText); + + progress.Reports.Should().NotBeEmpty(); + progress.Reports.Max(p => p.TotalUnits).Should().BeGreaterThan(assemblies.Count, + "the bar counts the files of every project put together, not whole assemblies -- an assembly-granular " + + "bar only moves when a project finishes, which for a solution of two is 0%, 50%, done"); + progress.Reports.Should().Contain(p => p.TotalUnits > 0 && p.UnitsCompleted < p.TotalUnits, + "a determinate report has to arrive while work is still outstanding; reporting only on completion " + + "leaves the tab showing an indeterminate spinner for the whole export"); + progress.Reports.Should().Contain(p => p.Status != null && p.Status.Contains(assemblies[0].ShortName), + "the status names the projects being written"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Solution_Progress_Completes_Even_When_A_Project_Cannot_Be_Written() + { + var (_, vm) = await TestHarness.BootAsync(); + var assemblies = await OpenFixtures(vm, 2); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSlnStuck_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // A file where the second project's directory needs to go: that project bails out before it + // writes anything, which used to leave its share of the bar outstanding forever. + await File.WriteAllTextAsync(Path.Combine(tempDir, assemblies[1].ShortName), "in the way"); + + var progress = new RecordingProgress(); + var result = await ProjectExporter.ExportAsync(assemblies, solutionMode: true, Options(tempDir), + new DecompilerSettings(), Language(), progress, CancellationToken.None); + + result.Success.Should().BeFalse("a project that cannot be written is a failed export"); + progress.Reports.Should().NotBeEmpty(); + var last = progress.Reports[^1]; + last.UnitsCompleted.Should().Be(last.TotalUnits, + "the bar has to close out when the export stops, even though one project never ran"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Solution_Mode_Skips_Assemblies_That_Failed_To_Load() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var broken = await vm.OpenBrokenFixtureAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSkipped_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([good, broken], solutionMode: true, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeTrue( + "one unloadable assembly must not sink the export of the ones that did load. Status:\n" + result.StatusText); + Directory.EnumerateFiles(Path.Combine(tempDir, good.ShortName), "*.csproj").Should().HaveCount(1, + "the assembly that loaded is still exported"); + Directory.Exists(Path.Combine(tempDir, broken.ShortName)).Should().BeFalse( + "there is nothing to decompile for an assembly that failed to load"); + result.StatusText.Should().Contain(broken.ShortName).And.Contain("failed to load", + "a skipped assembly is named in the report -- dropping it silently is what this replaces"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Export_Loads_An_Assembly_Whose_Load_Has_Not_Started() + { + var (_, vm) = await TestHarness.BootAsync(); + // A LoadedAssembly loads lazily: the tree constructs entries en masse and only the first + // await kicks the work off, so a selected-but-never-opened assembly reaches the exporter with + // its load untouched. Filtering the selection on a non-blocking status poll drops it as if it + // had failed, and the export writes nothing. + var assembly = new LoadedAssembly(vm.AssemblyTreeModel.AssemblyList!, FixtureAssembly.Emit("FixtureLazy")); + assembly.IsLoadedAsValidAssembly.Should().BeFalse("nothing has triggered the load yet"); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjLazy_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([assembly], solutionMode: false, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeTrue( + "an assembly whose load has not been started yet decompiles fine once awaited. Status:\n" + result.StatusText); + Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1); + result.StatusText.Should().NotContain("failed to load", + "a load that had not started is not a load that failed"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Solution_Mode_Reports_A_Metadata_Only_File_As_Such() + { + var (_, vm) = await TestHarness.BootAsync(); + var good = await vm.OpenFixtureAsync("FixtureA"); + var metadataOnly = await vm.OpenMetadataOnlyFixtureAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjMetaOnly_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([good, metadataOnly], solutionMode: true, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeTrue( + "a metadata-only file must not sink the export of the assembly that did load. Status:\n" + result.StatusText); + Directory.EnumerateFiles(Path.Combine(tempDir, good.ShortName), "*.csproj").Should().HaveCount(1); + result.StatusText.Should().Contain(metadataOnly.ShortName, + "a skipped file is named in the report"); + result.StatusText.Should().NotContain("failed to load", + "the file loaded; it just holds no code, and reporting a load failure sends the user " + + "looking for a corrupt file that is not there"); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Export_Reports_Failure_When_Nothing_In_The_Selection_Loaded() + { + var (_, vm) = await TestHarness.BootAsync(); + var broken = await vm.OpenBrokenFixtureAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjNoneLoaded_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync([broken], solutionMode: false, + Options(tempDir), new DecompilerSettings(), Language(), progress: null, CancellationToken.None); + + result.Success.Should().BeFalse("there is nothing left to export once the only assembly is skipped"); + result.StatusText.Should().Contain(broken.ShortName).And.Contain("failed to load"); + } + finally + { + TryDelete(tempDir); + } + } + [AvaloniaTest] public async Task StrongNameKeyFile_Is_Copied_Into_Project() { diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 016671924f..d55610d5c1 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -240,9 +240,9 @@ public override async void Execute(object? parameter) { // Several selected assemblies export a Visual Studio solution (one project each), // matching the Save Code context-menu entry. - if (SolutionExport.TryGetAssemblies(assemblyTreeModel.SelectedItems, out var assemblies)) + if (ProjectExport.TryGetSolutionAssemblies(assemblyTreeModel.SelectedItems, out var assemblies)) { - await SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace); + await ProjectExport.PromptAndExportSolutionAsync(assemblies, languageService.CurrentLanguage, dockWorkspace); return; } diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs index dd8d6e060f..c8ba8ea0e4 100644 --- a/ILSpy/Commands/ProjectExport.cs +++ b/ILSpy/Commands/ProjectExport.cs @@ -25,6 +25,8 @@ using global::Avalonia.Controls.ApplicationLifetimes; +using ICSharpCode.Decompiler; +using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpyX; using ICSharpCode.ILSpyX.TreeView; @@ -39,16 +41,27 @@ namespace ICSharpCode.ILSpy.Commands { /// - /// The shared launcher behind the dedicated "Export Project/Solution..." entry (File menu + - /// assembly context menu): recognises an exportable selection, shows - /// , then runs on a settings - /// clone behind the tab's cancellable progress UI and reports into the active decompiler tab. + /// The shared launcher for every flow that decompiles whole assemblies to disk. All of them + /// recognise their selection with and run + /// on a settings clone behind a frozen progress tab + /// (), differing only in how the target and options are chosen: + /// + /// "Export Project/Solution..." (File menu + assembly context menu) asks + /// for an output folder and per-run overrides. + /// Save Code on one assembly, with a project extension picked in the file dialog, exports + /// that project with the live settings (). + /// Save Code on several assemblies exports a solution to a picked .sln path, again + /// with the live settings (). + /// /// internal static class ProjectExport { /// - /// True when is one or more assembly nodes that all loaded as valid - /// assemblies. is set when more than one is selected. + /// True when is one or more assembly nodes, at least one of which loaded + /// as a valid assembly. Ones that failed to load stay in : the dialog + /// badges them and skips them with a line in the report, so a mixed + /// selection exports what it can instead of being turned away here. + /// is set when more than one node is selected. /// public static bool TryGetExportableAssemblies(IReadOnlyList? nodes, out List assemblies, out bool solutionMode) @@ -57,13 +70,38 @@ public static bool TryGetExportableAssemblies(IReadOnlyList? node solutionMode = false; if (nodes is not { Count: > 0 }) return false; - if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) + if (!nodes.All(n => n is AssemblyTreeNode)) return false; - assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); - solutionMode = assemblies.Count > 1; + + var selected = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); + // Nothing to write when every selected assembly failed to load. + if (!selected.Any(a => a.IsLoadedAsValidAssembly)) + return false; + + assemblies = selected; + solutionMode = selected.Count > 1; return true; } + /// + /// True when is the selection shape Save Code maps onto a solution: + /// several assembly nodes, on the terms sets out -- the + /// ones that did not load ride along in to be skipped and reported + /// by the exporter, rather than costing the whole selection its solution export. + /// + public static bool TryGetSolutionAssemblies(IReadOnlyList? nodes, + out List assemblies) + => TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode; + + /// + /// Awaits every assembly's load so a caller can read settled load state -- validity, metadata, + /// PDB eligibility -- without triggering or blocking on the lazy load itself. Uses the load + /// accessor that swallows load failures, so a broken assembly in the selection completes here + /// rather than faulting the whole batch. + /// + internal static Task EnsureAssembliesLoadedAsync(IReadOnlyList assemblies) + => Task.WhenAll(assemblies.Select(a => a.GetMetadataFileOrNullAsync())); + public static async Task PromptAndExportAsync(IReadOnlyList assemblies, bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService) { @@ -71,17 +109,106 @@ public static async Task PromptAndExportAsync(IReadOnlyList asse if (owner == null) return; + // Settle every selected assembly's load before building the dialog: its preview rows badge + // each one (valid / not a valid assembly / PDB-eligible) off the load result. Awaiting here + // means those reads see a completed load, instead of the dialog blocking the UI thread to + // force one as it builds the rows. + await EnsureAssembliesLoadedAsync(assemblies).ConfigureAwait(true); + var dialog = new ExportProjectDialog(settingsService, assemblies, solutionMode); var options = await dialog.ShowDialog(owner).ConfigureAwait(true); if (options is null || string.IsNullOrEmpty(options.OutputDirectory)) return; var settingsClone = settingsService.DecompilerSettings.Clone(); - // Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it. - await dockWorkspace.RunInNewTabAsync(ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution, async (token, progress) => { + await RunExportAsync(assemblies, solutionMode, options, settingsClone, language, dockWorkspace).ConfigureAwait(true); + } + + /// + /// Exports a single assembly as a decompiled project into . + /// This is the File -> Save Code path for a project extension. + /// + public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string outputDirectory, + DecompilerSettings settings, Language language, DockWorkspace dockWorkspace) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(dockWorkspace); + + return RunExportAsync(new List { assembly }, solutionMode: false, + OptionsFrom(settings, outputDirectory), settings.Clone(), language, dockWorkspace); + } + + /// + /// Prompts for a target .sln file and exports into it, one + /// decompiled project each. This is the File -> Save Code path for a multi-assembly selection. + /// Does nothing if the user cancels the picker. + /// + public static async Task PromptAndExportSolutionAsync(IReadOnlyList assemblies, + Language language, DockWorkspace dockWorkspace) + { + var path = await FilePickers.SaveAsync( + Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", + Resources._SaveCode).ConfigureAwait(true); + if (string.IsNullOrEmpty(path)) + return; + + var settings = AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() + ?? new DecompilerSettings(); + await ExportSolutionAsync(assemblies, path, settings, language, dockWorkspace).ConfigureAwait(true); + } + + /// + /// Exports as a solution written to , + /// one decompiled project each. Public so tests (and scripted callers) can bypass the file picker. + /// + public static Task ExportSolutionAsync(IReadOnlyList assemblies, string solutionPath, + DecompilerSettings settings, Language language, DockWorkspace dockWorkspace) + { + ArgumentNullException.ThrowIfNull(assemblies); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(dockWorkspace); + + var options = OptionsFrom(settings, Path.GetDirectoryName(solutionPath) ?? string.Empty, + Path.GetFileName(solutionPath)); + return RunExportAsync(assemblies, solutionMode: true, options, settings.Clone(), language, dockWorkspace); + } + + // The Save Code paths take the settings as they stand instead of asking for per-run overrides, so + // mirror those settings into the options: ProjectExporter.ApplyOverrides then leaves the settings + // clone alone and the output matches a plain decompile, only with progress surfaced. No PDB and no + // strong-name key either -- both are dialog-only features. + static ProjectExportOptions OptionsFrom(DecompilerSettings settings, string outputDirectory, + string? solutionFileName = null) + => new( + OutputDirectory: outputDirectory, + UseSdkStyleProjectFormat: settings.UseSdkStyleProjectFormat, + UseNestedDirectoriesForNamespaces: settings.UseNestedDirectoriesForNamespaces, + RemoveDeadCode: settings.RemoveDeadCode, + RemoveDeadStores: settings.RemoveDeadStores, + UseDebugSymbols: settings.UseDebugSymbols, + StrongNameKeyFile: null, + GeneratePdb: false, + EmbedSourceFilesInPdb: false, + SolutionFileName: solutionFileName); + + // Runs the export behind a dedicated frozen tab (so browsing the tree while it runs can't cancel it) + // and reports the result there, with an Open-folder button on success. The tab is titled after the + // assemblies being exported, joining their full tree-node labels the same way + // DecompilerTabPageModel.ComposeBaseTitle titles a multi-node decompile tab -- so a single-assembly + // export reads as that assembly and a solution export as its members, ellipsised on the tab with the + // full list shown as a tooltip. + static Task RunExportAsync(IReadOnlyList assemblies, bool solutionMode, + ProjectExportOptions options, DecompilerSettings settingsClone, Language language, + DockWorkspace dockWorkspace) + { + var title = assemblies.Count > 0 + ? string.Join(", ", assemblies.Select(a => a.Text)) + : Resources.ExportProjectSolution; + return dockWorkspace.RunInNewTabAsync(title, async (token, progress) => { var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, progress, token) .ConfigureAwait(false); - var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution }; + var o = new AvaloniaEditTextOutput { Title = title }; o.Write(result.StatusText); o.WriteLine(); if (result.Success && Directory.Exists(options.OutputDirectory)) diff --git a/ILSpy/Commands/ProjectExportOptions.cs b/ILSpy/Commands/ProjectExportOptions.cs index 4f504bf0bd..cbade909db 100644 --- a/ILSpy/Commands/ProjectExportOptions.cs +++ b/ILSpy/Commands/ProjectExportOptions.cs @@ -19,10 +19,16 @@ namespace ICSharpCode.ILSpy.Commands { /// - /// The configuration chosen in the Export Project/Solution dialog and consumed by - /// . The format/decompiler flags are applied onto a clone of the - /// live decompiler settings (never the persisted instance). + /// The configuration for one export run, consumed by : chosen in the + /// Export Project/Solution dialog, or mirrored off the live settings by the Save Code paths. The + /// format/decompiler flags are applied onto a clone of the live decompiler settings (never the + /// persisted instance). /// + /// + /// In solution mode, the name of the .sln to write inside . + /// null names it after that directory, which is what the dialog wants (it only asks for a + /// folder); Save Code sets it, because there the user picks the solution file itself. + /// public sealed record ProjectExportOptions( string OutputDirectory, bool UseSdkStyleProjectFormat, @@ -32,5 +38,6 @@ public sealed record ProjectExportOptions( bool UseDebugSymbols, string? StrongNameKeyFile, bool GeneratePdb, - bool EmbedSourceFilesInPdb); + bool EmbedSourceFilesInPdb, + string? SolutionFileName = null); } diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs index e5309c3861..ddff5bdf75 100644 --- a/ILSpy/Commands/ProjectExporter.cs +++ b/ILSpy/Commands/ProjectExporter.cs @@ -19,6 +19,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -37,9 +38,10 @@ namespace ICSharpCode.ILSpy.Commands /// /// The UI-agnostic engine behind the Export Project/Solution dialog. Unifies single-assembly /// project export and multi-assembly solution export (the latter via ), - /// applies the dialog's overrides onto a settings clone, and optionally emits a portable PDB per - /// assembly. Returns a the caller surfaces in the text view. - /// Kept separate from the dialog so it is headless-testable. + /// applies the dialog's overrides onto a settings clone, skips (and reports) the entries with no code + /// behind them, and optionally emits a portable PDB per assembly. Returns a + /// the caller surfaces in the text view. Kept separate from the + /// dialog so it is headless-testable. /// internal static class ProjectExporter { @@ -55,25 +57,80 @@ public static async Task ExportAsync( ApplyOverrides(settingsClone, options); + // Resolve each entry by awaiting its load. IsLoadedAsValidAssembly cannot stand in for this: + // it is a non-blocking status poll that reads false for a load still running or not yet + // started (LoadedAssembly builds its entries lazily), so filtering on it here would drop + // assemblies that decompile perfectly well once awaited. The callers' selection predicate has + // to poll -- it answers IsEnabled on the UI thread -- but this runs off it and can wait. + var loaded = await Task.WhenAll(assemblies.Select(async a => ( + Assembly: a, + File: await a.GetMetadataFileOrNullAsync().ConfigureAwait(false) + ))).ConfigureAwait(false); + + // Anything without a PE image behind it has nothing to decompile: a file that failed to load, + // or one that carries metadata only (a standalone PDB, say). Leave those out and name them in + // the report, so a mixed selection still exports what it can and the user is told what is + // missing rather than having to notice it. + var exportable = loaded.Where(e => e.File is { IsMetadataOnly: false }).Select(e => e.Assembly).ToList(); + var skipReport = SkipReport(loaded.Where(e => e.File is not { IsMetadataOnly: false })); + if (exportable.Count == 0) + { + return new SolutionExportResult(false, + "Nothing to export." + Environment.NewLine + skipReport); + } + if (solutionMode) { - var solutionFilePath = Path.Combine(options.OutputDirectory, SolutionFileName(options.OutputDirectory)); + var solutionFilePath = Path.Combine(options.OutputDirectory, + options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory)); var solution = await SolutionWriter.CreateSolutionAsync( - solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile, progress) + solutionFilePath, language, exportable, ct, settingsClone, options.StrongNameKeyFile, progress) .ConfigureAwait(false); var report = new StringBuilder(solution.StatusText); if (options.GeneratePdb && solution.Success) { - await Task.Run(() => GeneratePdbs(assemblies, + await Task.Run(() => GeneratePdbs(exportable, a => Path.Combine(options.OutputDirectory, a.ShortName), settingsClone, options, report, ct), ct) .ConfigureAwait(false); } + AppendSkipReport(report, skipReport); return new SolutionExportResult(solution.Success, report.ToString()); } - return await Task.Run(() => ExportProject(assemblies[0], options, settingsClone, language, progress, ct), ct) + var projectResult = await Task + .Run(() => ExportProject(exportable[0], options, settingsClone, language, progress, ct), ct) .ConfigureAwait(false); + if (skipReport.Length == 0) + return projectResult; + + var projectReport = new StringBuilder(projectResult.StatusText); + AppendSkipReport(projectReport, skipReport); + return projectResult with { StatusText = projectReport.ToString() }; + } + + // One line per assembly left out of the export, saying which of the two reasons applies: a file + // that never loaded is a different problem from one that loaded and holds no code, and sending + // the user after a corrupt file that is not there wastes their time. + static string SkipReport(IEnumerable<(LoadedAssembly Assembly, MetadataFile? File)> skipped) + { + var report = new StringBuilder(); + foreach (var (assembly, file) in skipped) + { + var reason = file == null + ? "the assembly failed to load" + : "it holds metadata only, with no code to decompile"; + report.AppendLine($"Skipped '{assembly.ShortName}': {reason}."); + } + return report.ToString(); + } + + static void AppendSkipReport(StringBuilder report, string skipReport) + { + if (skipReport.Length == 0) + return; + report.AppendLine(); + report.Append(skipReport); } static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options, @@ -159,8 +216,12 @@ static void ApplyOverrides(DecompilerSettings settings, ProjectExportOptions opt settings.UseDebugSymbols = options.UseDebugSymbols; } - // The .sln is named after the chosen output folder, falling back to "Solution.sln". - static string SolutionFileName(string outputDirectory) + /// + /// The .sln name used when is unset: + /// after the chosen output folder, falling back to "Solution.sln". Internal so the export dialog + /// can show the same name as its watermark instead of predicting it. + /// + internal static string SolutionFileNameFor(string outputDirectory) { var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs index f953997922..a1f728a4c7 100644 --- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs +++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs @@ -17,7 +17,6 @@ // DEALINGS IN THE SOFTWARE. using System.Composition; -using System.Linq; using ICSharpCode.ILSpy.Properties; using ICSharpCode.ILSpyX.TreeView; @@ -35,7 +34,7 @@ namespace ICSharpCode.ILSpy.Commands /// AssemblyTreeNode drives project / single-file selection, ResourceTreeNode /// writes raw bytes, every other node falls through to the generic single-file decompile. /// Several selected assemblies export a Visual Studio solution (one decompiled project - /// each) via . + /// each) via . /// /// [ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 300)] @@ -62,9 +61,9 @@ public void Execute(TextViewContext context) if (nodes is not { Length: > 0 }) return; - if (SolutionExport.TryGetAssemblies(nodes, out var assemblies)) + if (ProjectExport.TryGetSolutionAssemblies(nodes, out var assemblies)) { - SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions(); + ProjectExport.PromptAndExportSolutionAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions(); return; } @@ -84,7 +83,7 @@ static bool CanExecute(SharpTreeNode[]? selectedNodes) return false; if (selectedNodes.Length == 1) return selectedNodes[0] is ILSpyTreeNode; - return SolutionExport.TryGetAssemblies(selectedNodes, out _); + return ProjectExport.TryGetSolutionAssemblies(selectedNodes, out _); } } } diff --git a/ILSpy/Commands/SaveCodeHelper.cs b/ILSpy/Commands/SaveCodeHelper.cs index b96e570249..921467d085 100644 --- a/ILSpy/Commands/SaveCodeHelper.cs +++ b/ILSpy/Commands/SaveCodeHelper.cs @@ -68,6 +68,24 @@ public static async Task SaveNodeAsync(ILSpyTreeNode node, LanguageService langu if (path == null) return; + await SaveNodeToFileWithProgressAsync(node, language, path, dockWorkspace).ConfigureAwait(true); + } + + /// + /// Decompiles into behind the active tab's + /// cancellable progress overlay, then shows a "decompilation complete" breadcrumb (with an + /// Open-folder button) in that tab. Shared by the generic Save Code fallback and the assembly + /// single-file save so both get the same progress/cancel UX. The caller has already settled on + /// ; cancelling here means cancelling the decompilation from the overlay, + /// which leaves the tab's contents as they were. + /// + public static async Task SaveNodeToFileWithProgressAsync(ILSpyTreeNode node, Language language, + string path, DockWorkspace dockWorkspace) + { + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(language); + ArgumentNullException.ThrowIfNull(dockWorkspace); + try { // Run the decompile-to-file behind the tab's cancellable progress overlay, then show a diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs deleted file mode 100644 index 4dce21bd13..0000000000 --- a/ILSpy/Commands/SolutionExport.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team -// -// Permission is hereby granted, free of charge, to any person obtaining a copy of this -// software and associated documentation files (the "Software"), to deal in the Software -// without restriction, including without limitation the rights to use, copy, modify, merge, -// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons -// to whom the Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all copies or -// substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE -// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -using ICSharpCode.ILSpy.Properties; -using ICSharpCode.ILSpyX; -using ICSharpCode.ILSpyX.TreeView; - -using ICSharpCode.ILSpy.Docking; -using ICSharpCode.ILSpy.Languages; -using ICSharpCode.ILSpy.TextView; -using ICSharpCode.ILSpy.TreeNodes; -using ICSharpCode.ILSpy.Util; - -namespace ICSharpCode.ILSpy.Commands -{ - /// - /// The shared "export several assemblies as a Visual Studio solution" flow behind both the - /// File → Save Code command and the Save Code context-menu entry: it recognises a - /// solution-eligible selection, prompts for the .sln path, runs - /// behind the tab's cancellable progress UI, and surfaces the - /// status report in the active decompiler tab. - /// - internal static class SolutionExport - { - /// - /// True when is several assembly nodes that all loaded as valid - /// assemblies — the only selection shape that maps onto a multi-project solution. - /// - public static bool TryGetAssemblies(IReadOnlyList? nodes, out List assemblies) - { - assemblies = new List(); - if (nodes is not { Count: > 1 }) - return false; - if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) - return false; - assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); - return true; - } - - /// - /// Prompts for a target .sln file and exports into it, - /// one decompiled project each. The export runs in its own frozen tab so browsing the tree - /// can't cancel it, and the status report lands there. Does nothing if the user cancels the picker. - /// - public static async Task PromptAndExportAsync(IReadOnlyList assemblies, - Language language, DockWorkspace dockWorkspace) - { - var path = await FilePickers.SaveAsync( - Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", Resources._SaveCode) - .ConfigureAwait(true); - if (string.IsNullOrEmpty(path)) - return; - - // Snapshot the user's current decompiler settings for the whole export, like the - // per-project export path does. - var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() - ?? new ICSharpCode.Decompiler.DecompilerSettings(); - - // Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it. - await dockWorkspace.RunInNewTabAsync("Exporting solution", async (token, progress) => { - var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token, - settings, strongNameKeyFile: null, progress: progress) - .ConfigureAwait(false); - var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode }; - o.Write(result.StatusText); - o.WriteLine(); - if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory) - { - o.AddOpenFolderButton(directory); - } - return o; - }).ConfigureAwait(true); - } - - } -} diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs index 81c931fdee..3ebe1f2e43 100644 --- a/ILSpy/SolutionWriter.cs +++ b/ILSpy/SolutionWriter.cs @@ -82,7 +82,14 @@ public static Task CreateSolutionAsync(string solutionFile readonly IProgress? progress; readonly ConcurrentBag projects; readonly ConcurrentBag statusOutput; - int completedAssemblies; + + // How far each project has got, keyed by assembly short name -- unique, because duplicate names + // abort the export before any project runs. The workers fill these in as they decompile and the + // progress bar shows their sum. + readonly ConcurrentDictionary projectProgress; + // The projects in selection order, so the status label lists them in a stable order rather than + // in whatever order the workers happen to reach them. + string[] projectOrder; SolutionWriter(string solutionFilePath, DecompilerSettings settings, string? strongNameKeyFile, IProgress? progress) @@ -94,6 +101,32 @@ public static Task CreateSolutionAsync(string solutionFile solutionDirectory = Path.GetDirectoryName(solutionFilePath)!; statusOutput = new ConcurrentBag(); projects = new ConcurrentBag(); + projectProgress = new ConcurrentDictionary(); + projectOrder = Array.Empty(); + } + + /// How much of one project's file list has been written, and whether it is still running. + sealed class ProjectProgress + { + public int FilesWritten; + public int FileCount; + public bool Running; + } + + /// + /// Feeds one project's file counts into the shared total. + /// reports its whole file count with every report, so the solution bar knows a project's size from + /// its first written file rather than only once the project is done. + /// + sealed class ProjectProgressReporter(SolutionWriter writer, ProjectProgress project) + : IProgress + { + public void Report(DecompilationProgress value) + { + project.FileCount = value.TotalUnits; + project.FilesWritten = value.UnitsCompleted; + writer.ReportProgress(); + } } async Task CreateSolutionAsync(IReadOnlyList allAssemblies, @@ -123,13 +156,15 @@ async Task CreateSolutionAsync(IReadOnlyList a.ShortName).ToArray(); + try { // An explicit enumerable partitioner avoids Parallel.ForEach's list special-casing, // whose static partitioning is inefficient when assemblies decompile at different speeds. await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies), new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct }, - item => WriteProject(item, language, solutionDirectory, allAssemblies.Count, ct))) + item => WriteProject(item, language, solutionDirectory, ct))) .ConfigureAwait(false); if (projects.Count == 0) @@ -183,15 +218,68 @@ await Task.Run(() => SolutionCreator.WriteSolutionFile(solutionFilePath, project return new SolutionExportResult(true, report.ToString()); } - void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, int totalAssemblies, CancellationToken ct) + // Reports the whole solution's progress: the file counts of every project added up. The projects + // are decompiled in parallel, so no single one of them can drive the bar; summing them lets it + // move continuously and, because a project reports its file count as soon as it writes its first + // file, turn determinate right after the export starts. Racing reads are fine here -- the worst a + // report that races a worker can be is a file or two out of date. + void ReportProgress() { - // Solution export decompiles assemblies in parallel, so per-file progress would race; report - // at the coarser assembly granularity instead -- a determinate bar over the assembly count. - void ReportDone() => progress?.Report(new DecompilationProgress { - TotalUnits = totalAssemblies, - UnitsCompleted = System.Threading.Interlocked.Increment(ref completedAssemblies), - Status = loadedAssembly.ShortName, + if (progress == null) + return; + + int filesWritten = 0, fileCount = 0; + foreach (var project in projectProgress.Values) + { + filesWritten += project.FilesWritten; + fileCount += project.FileCount; + } + + progress.Report(new DecompilationProgress { + TotalUnits = fileCount, + UnitsCompleted = filesWritten, + Title = "Exporting solution...", + Status = RunningProjects(), }); + } + + // The projects being written right now, so the label says what is running instead of naming + // whichever project happened to report last. Long selections are cut short: the bar is not the + // place to list twenty assemblies. + string RunningProjects() + { + const int maxNames = 3; + var running = projectOrder + .Where(name => projectProgress.TryGetValue(name, out var project) && project.Running) + .ToList(); + return running.Count <= maxNames + ? string.Join(", ", running) + : string.Join(", ", running.Take(maxNames)) + $" and {running.Count - maxNames} more"; + } + + void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct) + { + var project = new ProjectProgress { Running = true }; + projectProgress[loadedAssembly.ShortName] = project; + ReportProgress(); + try + { + WriteProjectCore(loadedAssembly, language, targetDirectory, project, ct); + } + finally + { + // Whatever became of the project -- written, bailed out before it started, or cancelled -- + // it stops counting against the total here. Leaving an abandoned project's files + // outstanding would strand the bar short of the end for the rest of the export. + project.FilesWritten = project.FileCount; + project.Running = false; + ReportProgress(); + } + } + + void WriteProjectCore(LoadedAssembly loadedAssembly, Language language, string targetDirectory, + ProjectProgress project, CancellationToken ct) + { targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName); if (language.ProjectFileExtension == null) @@ -230,6 +318,7 @@ void ReportDone() => progress?.Report(new DecompilationProgress { options.CancellationToken = ct; options.SaveAsProjectDirectory = targetDirectory; options.StrongNameKeyFile = strongNameKeyFile; + options.ProgressIndicator = new ProjectProgressReporter(this, project); // The project-export path writes the .csproj into SaveAsProjectDirectory itself; the // ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here. @@ -260,7 +349,6 @@ void ReportDone() => progress?.Report(new DecompilationProgress { statusOutput.Add("-------------"); statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}"); } - ReportDone(); } } } diff --git a/ILSpy/TreeNodes/AssemblyTreeNode.cs b/ILSpy/TreeNodes/AssemblyTreeNode.cs index ba94e57c51..942de3377c 100644 --- a/ILSpy/TreeNodes/AssemblyTreeNode.cs +++ b/ILSpy/TreeNodes/AssemblyTreeNode.cs @@ -190,17 +190,40 @@ async Task SaveAsProjectOrSingleFileAsync(Language language) + $"|{language.Name} (*{language.FileExtension})|*{language.FileExtension}" + "|All files (*.*)|*.*"; var defaultName = WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, language.ProjectFileExtension); - var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(false); + var path = await Commands.FilePickers.SaveAsync(filter, defaultName, "Save Code").ConfigureAwait(true); if (string.IsNullOrEmpty(path)) return; var ext = Path.GetExtension(path); var isProject = string.Equals(ext, language.ProjectFileExtension, StringComparison.OrdinalIgnoreCase); - var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() + var dockWorkspace = AppEnv.AppComposition.TryGetExport(); + + // A whole-assembly .csproj export is the expensive case, so route it through the same + // determinate-progress export path as the Export Project command (a dedicated frozen tab, so + // browsing the tree while it runs can't cancel it). + if (isProject && dockWorkspace != null + && Path.GetDirectoryName(path) is { Length: > 0 } projectDirectory) + { + var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() + ?? new ICSharpCode.Decompiler.DecompilerSettings(); + await Commands.ProjectExport.ExportSingleAssemblyAsync( + assembly, projectDirectory, settings, language, dockWorkspace).ConfigureAwait(true); + return; + } + + // Single-file save: the same cancellable progress overlay normal decompilation uses. + if (dockWorkspace != null) + { + await Commands.SaveCodeHelper.SaveNodeToFileWithProgressAsync(this, language, path, dockWorkspace).ConfigureAwait(true); + return; + } + + // No DockWorkspace (non-interactive host): fall back to a plain, non-cancellable write. + var fallbackSettings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings() ?? new ICSharpCode.Decompiler.DecompilerSettings(); await Task.Run(() => { - var options = new DecompilationOptions(settings) { + var options = new DecompilationOptions(fallbackSettings) { FullDecompilation = true, EscapeInvalidIdentifiers = true, }; diff --git a/ILSpy/Views/ExportProjectDialog.axaml b/ILSpy/Views/ExportProjectDialog.axaml index ab360dd341..a467f4388a 100644 --- a/ILSpy/Views/ExportProjectDialog.axaml +++ b/ILSpy/Views/ExportProjectDialog.axaml @@ -2,7 +2,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:views="clr-namespace:ICSharpCode.ILSpy.Views" x:Class="ICSharpCode.ILSpy.Views.ExportProjectDialog" - Width="560" Height="580" MinWidth="460" MinHeight="440" + Width="560" MinWidth="460" MinHeight="440" MaxHeight="900" + SizeToContent="Height" WindowStartupLocation="CenterOwner" Title="Export Project"> @@ -11,7 +12,7 @@