From 60405af40793e71f3b3b3f75571aa0ffda6dd3f7 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 6 Jun 2026 20:09:44 +0200 Subject: [PATCH] Add a configurable Export Project/Solution dialog The quick Save Code path exports a project (one assembly) or a solution (several) straight through a file picker, with no way to tune the output. Add a dedicated, discoverable "Export Project/Solution..." entry (File menu + assembly context menu, alongside Save Code) that opens a configuration dialog: pick the output folder, preview the projects that will be written (with invalid / duplicate-name / PDB-eligible badges), toggle project-format and decompiler options, optionally sign with a strong-name key, and optionally emit a portable PDB per assembly -- defaulting source-embedding off since the project's .cs are on disk. The export runs on a clone of the live settings (never the persisted instance) behind the tab's cancellable progress UI and reports into the active decompiler tab. StrongNameKeyFile now flows through DecompilationOptions into the project writer, which had no reachable setter before. The engine (ProjectExporter) and the preview computation are split out from the window so they are headless-testable. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../ContextMenus/ExportProjectEntryTests.cs | 114 ++++++++++ .../Languages/ExportPreviewRowTests.cs | 85 ++++++++ .../Languages/ProjectExportRunnerTests.cs | 203 ++++++++++++++++++ .../Commands/ExportProjectContextMenuEntry.cs | 64 ++++++ ILSpy/Commands/FileCommands.cs | 23 +- ILSpy/Commands/ProjectExport.cs | 120 +++++++++++ ILSpy/Commands/ProjectExportOptions.cs | 36 ++++ ILSpy/Commands/ProjectExporter.cs | 167 ++++++++++++++ ILSpy/DecompilationOptions.cs | 9 + ILSpy/Languages/CSharpLanguage.cs | 4 + ILSpy/Properties/Resources.Designer.cs | 9 + ILSpy/Properties/Resources.resx | 3 + ILSpy/SolutionWriter.cs | 28 ++- ILSpy/Views/ExportProjectDialog.axaml | 58 +++++ ILSpy/Views/ExportProjectDialog.axaml.cs | 202 +++++++++++++++++ 15 files changed, 1115 insertions(+), 10 deletions(-) create mode 100644 ILSpy.Tests/ContextMenus/ExportProjectEntryTests.cs create mode 100644 ILSpy.Tests/Languages/ExportPreviewRowTests.cs create mode 100644 ILSpy.Tests/Languages/ProjectExportRunnerTests.cs create mode 100644 ILSpy/Commands/ExportProjectContextMenuEntry.cs create mode 100644 ILSpy/Commands/ProjectExport.cs create mode 100644 ILSpy/Commands/ProjectExportOptions.cs create mode 100644 ILSpy/Commands/ProjectExporter.cs create mode 100644 ILSpy/Views/ExportProjectDialog.axaml create mode 100644 ILSpy/Views/ExportProjectDialog.axaml.cs diff --git a/ILSpy.Tests/ContextMenus/ExportProjectEntryTests.cs b/ILSpy.Tests/ContextMenus/ExportProjectEntryTests.cs new file mode 100644 index 000000000..85efbf406 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/ExportProjectEntryTests.cs @@ -0,0 +1,114 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.TextView; +using ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Visibility/enablement of the dedicated "Export Project/Solution..." entry (context menu + File +/// menu): one or more valid assemblies qualify; any other selection does not. The quick Save Code +/// entry is unaffected. +/// +[TestFixture] +public class ExportProjectEntryTests +{ + static IContextMenuEntry Entry() + => AppComposition.Current.GetExport().GetEntry(nameof(Resources.ExportProjectSolution)); + + static TextViewContext Context(params SharpTreeNode[] nodes) + => new() { SelectedTreeNodes = nodes }; + + [AvaloniaTest] + public async Task Single_Assembly_Is_Visible() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = vm.AssemblyTreeModel.FindNode("System.Linq"); + Assert.That(assembly, Is.Not.Null); + + Entry().IsVisible(Context(assembly!)).Should().BeTrue("a single valid assembly offers project export"); + } + + [AvaloniaTest] + public async Task Multiple_Assemblies_Are_Visible() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Where(a => a.IsLoadedAsValidAssembly) + .GroupBy(a => a.ShortName).Select(g => g.First()) + .Take(2) + .Select(a => (SharpTreeNode)new AssemblyTreeNode(a)) + .ToArray(); + assemblies.Should().HaveCount(2); + + Entry().IsVisible(Context(assemblies)).Should().BeTrue("several valid assemblies offer solution export"); + } + + [AvaloniaTest] + public async Task Mixed_Selection_Is_Hidden() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = vm.AssemblyTreeModel.FindNode("System.Linq"); + var type = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + Assert.That(assembly, Is.Not.Null); + Assert.That(type, Is.Not.Null); + + Entry().IsVisible(Context(assembly!, type!)).Should().BeFalse( + "only assembly selections map onto a project/solution export"); + } + + [AvaloniaTest] + public async Task Empty_Selection_Is_Hidden() + { + await TestHarness.BootAsync(1); + Entry().IsVisible(Context()).Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Main_Menu_Command_Enables_Only_For_Assembly_Selections() + { + var (_, vm) = await TestHarness.BootAsync(3); + var command = AppComposition.Current.GetExport() + .GetCommand(nameof(Resources.ExportProjectSolution)); + + command.CanExecute(null).Should().BeFalse("nothing selected at startup"); + + var assembly = vm.AssemblyTreeModel.FindNode("System.Linq"); + Assert.That(assembly, Is.Not.Null); + vm.AssemblyTreeModel.SelectNode(assembly); + + command.CanExecute(null).Should().BeTrue("a selected assembly enables Export Project/Solution"); + } +} diff --git a/ILSpy.Tests/Languages/ExportPreviewRowTests.cs b/ILSpy.Tests/Languages/ExportPreviewRowTests.cs new file mode 100644 index 000000000..034492fc8 --- /dev/null +++ b/ILSpy.Tests/Languages/ExportPreviewRowTests.cs @@ -0,0 +1,85 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Languages; + +/// +/// The Export Project/Solution dialog's preview computation (extracted as a pure static so it is +/// testable without the window): per-assembly project name, target subdirectory, and the +/// invalid / duplicate-name / PDB-eligible badges. +/// +[TestFixture] +public class ExportPreviewRowTests +{ + [AvaloniaTest] + public async Task Solution_Mode_Uses_ShortName_Subdirectories_And_Flags_No_Duplicates() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assemblies = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Where(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName) + .GroupBy(a => a.ShortName).Select(g => g.First()) + .Take(2).ToList(); + assemblies.Should().HaveCount(2); + + var rows = ExportProjectDialog.BuildPreviewRows(assemblies, solutionMode: true); + + rows.Should().HaveCount(2); + rows.Should().OnlyContain(r => r.IsValidAssembly); + rows.Should().OnlyContain(r => !r.HasDuplicateShortName); + rows.Select(r => r.TargetSubdirectory).Should().BeEquivalentTo(assemblies.Select(a => a.ShortName)); + rows.Select(r => r.ProjectName).Should().BeEquivalentTo(assemblies.Select(a => a.ShortName + ".csproj")); + } + + [AvaloniaTest] + public async Task Project_Mode_Writes_Into_The_Chosen_Folder_Directly() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .First(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName); + + var rows = ExportProjectDialog.BuildPreviewRows(new[] { assembly }, solutionMode: false); + + rows.Single().TargetSubdirectory.Should().Be(".", + "project mode writes the single project into the chosen folder, not a subdirectory"); + } + + [AvaloniaTest] + public async Task Duplicate_Short_Names_Are_Flagged_On_Every_Affected_Row() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .First(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName); + + var rows = ExportProjectDialog.BuildPreviewRows(new[] { assembly, assembly }, solutionMode: true); + + rows.Should().HaveCount(2); + rows.Should().OnlyContain(r => r.HasDuplicateShortName); + rows.Should().OnlyContain(r => r.BadgeText.Contains("duplicate name")); + } +} diff --git a/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs new file mode 100644 index 000000000..9f0a1afbe --- /dev/null +++ b/ILSpy.Tests/Languages/ProjectExportRunnerTests.cs @@ -0,0 +1,203 @@ +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler; +using ICSharpCode.ILSpyX; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.Languages; +using ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Languages; + +/// +/// End-to-end tests of , the headless engine behind the Export +/// Project/Solution dialog: project mode (one assembly), solution mode (several), optional PDB +/// emission, strong-name key copy, and the invariant that it never mutates persisted settings. +/// +[TestFixture] +public class ProjectExportRunnerTests +{ + static CSharpLanguage Language() + => AppComposition.Current.GetExport().Languages.OfType().First(); + + static List NonCoreLibAssemblies(MainWindowViewModel vm, int count) + => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() + .Where(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName) + .GroupBy(a => a.ShortName).Select(g => g.First()) + .Take(count).ToList(); + + static ProjectExportOptions Options(string outputDir, bool generatePdb = false, + bool embedSourceFilesInPdb = false, string? strongNameKeyFile = null) + => new(outputDir, + UseSdkStyleProjectFormat: true, + UseNestedDirectoriesForNamespaces: false, + RemoveDeadCode: false, + RemoveDeadStores: false, + UseDebugSymbols: false, + StrongNameKeyFile: strongNameKeyFile, + GeneratePdb: generatePdb, + EmbedSourceFilesInPdb: embedSourceFilesInPdb); + + // PDB generation is exercised against a controlled assembly in + // ICSharpCode.Decompiler.Tests.PdbGenerationTestRunner. It is deliberately NOT run here against + // framework assemblies: the writer's DEBUG-only duplicate-sequence-point Debug.Assert (which also + // [Ignore]s the Members PDB fixture) aborts the test host in a Debug build. The runner's project/ + // solution structure and settings handling are what these tests cover. + + [AvaloniaTest] + public async Task Project_Mode_Writes_Csproj_And_Cs() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = NonCoreLibAssemblies(vm, 1).Single(); + await assembly.GetLoadResultAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProj_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync( + new[] { assembly }, solutionMode: false, Options(tempDir), + new DecompilerSettings(), Language(), CancellationToken.None); + + result.Success.Should().BeTrue(result.StatusText); + Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1); + Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(); + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task Solution_Mode_Writes_Sln_And_Projects() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assemblies = NonCoreLibAssemblies(vm, 2); + assemblies.Should().HaveCountGreaterThanOrEqualTo(2); + foreach (var a in assemblies) + await a.GetLoadResultAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSln_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var result = await ProjectExporter.ExportAsync( + assemblies, solutionMode: true, Options(tempDir), + new DecompilerSettings(), Language(), CancellationToken.None); + + result.Success.Should().BeTrue(result.StatusText); + Directory.EnumerateFiles(tempDir, "*.sln").Should().HaveCount(1); + foreach (var a in assemblies) + { + var projectDir = Path.Combine(tempDir, a.ShortName); + Directory.EnumerateFiles(projectDir, "*.csproj").Should().HaveCount(1, a.ShortName); + } + } + finally + { + TryDelete(tempDir); + } + } + + [AvaloniaTest] + public async Task StrongNameKeyFile_Is_Copied_Into_Project() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = NonCoreLibAssemblies(vm, 1).Single(); + await assembly.GetLoadResultAsync(); + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSnk_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var keyFile = Path.Combine(Path.GetTempPath(), "ILSpyKey_" + System.Guid.NewGuid().ToString("N") + ".snk"); + await File.WriteAllBytesAsync(keyFile, new byte[] { 1, 2, 3, 4 }); + try + { + var result = await ProjectExporter.ExportAsync( + new[] { assembly }, solutionMode: false, Options(tempDir, strongNameKeyFile: keyFile), + new DecompilerSettings(), Language(), CancellationToken.None); + + result.Success.Should().BeTrue(result.StatusText); + File.Exists(Path.Combine(tempDir, Path.GetFileName(keyFile))).Should().BeTrue( + "the strong-name key must be copied next to the exported project"); + } + finally + { + TryDelete(tempDir); + TryDeleteFile(keyFile); + } + } + + [AvaloniaTest] + public async Task Runner_Does_Not_Mutate_Persisted_Settings() + { + var (_, vm) = await TestHarness.BootAsync(3); + var assembly = NonCoreLibAssemblies(vm, 1).Single(); + await assembly.GetLoadResultAsync(); + + var settingsService = AppComposition.Current.GetExport(); + bool originalSdk = settingsService.DecompilerSettings.UseSdkStyleProjectFormat; + + var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjNoMutate_" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + // Flip every settings-backed toggle relative to the persisted value, on a CLONE. + var options = Options(tempDir) with { UseSdkStyleProjectFormat = !originalSdk }; + await ProjectExporter.ExportAsync( + new[] { assembly }, solutionMode: false, options, + settingsService.DecompilerSettings.Clone(), Language(), CancellationToken.None); + + settingsService.DecompilerSettings.UseSdkStyleProjectFormat.Should().Be(originalSdk, + "exporting must apply overrides to a clone, never the persisted settings"); + } + finally + { + TryDelete(tempDir); + } + } + + static void TryDelete(string dir) + { + try + { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } + + static void TryDeleteFile(string file) + { + try + { File.Delete(file); } + catch { /* best-effort */ } + } +} diff --git a/ILSpy/Commands/ExportProjectContextMenuEntry.cs b/ILSpy/Commands/ExportProjectContextMenuEntry.cs new file mode 100644 index 000000000..e65bb72e6 --- /dev/null +++ b/ILSpy/Commands/ExportProjectContextMenuEntry.cs @@ -0,0 +1,64 @@ +// 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.Composition; + +using ICSharpCode.ILSpy.Properties; + +using ILSpy.Docking; +using ILSpy.Languages; + +namespace ILSpy.Commands +{ + /// + /// Right-click one or more assemblies -> "Export Project/Solution...". Opens the configuration + /// dialog (project mode for a single assembly, solution mode for several). Sits alongside the + /// quick "Save Code" entry, which keeps its no-dialog behaviour. + /// + [ExportContextMenuEntry(Header = nameof(Resources.ExportProjectSolution), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 301)] + [Shared] + public sealed class ExportProjectContextMenuEntry : IContextMenuEntry + { + readonly LanguageService languageService; + readonly DockWorkspace dockWorkspace; + readonly SettingsService settingsService; + + [ImportingConstructor] + public ExportProjectContextMenuEntry(LanguageService languageService, + DockWorkspace dockWorkspace, SettingsService settingsService) + { + this.languageService = languageService; + this.dockWorkspace = dockWorkspace; + this.settingsService = settingsService; + } + + public bool IsVisible(TextViewContext context) + => ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out _, out _); + + public bool IsEnabled(TextViewContext context) + => ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out _, out _); + + public void Execute(TextViewContext context) + { + if (!ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out var assemblies, out var solutionMode)) + return; + _ = ProjectExport.PromptAndExportAsync(assemblies, solutionMode, + languageService.CurrentLanguage, dockWorkspace, settingsService); + } + } +} diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 8ffc220be..88b2a2815 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -284,7 +284,28 @@ namespace ILSpy.Commands } } - [ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.GeneratePortable), MenuCategory = nameof(Resources.Save), MenuOrder = 21)] + [ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.ExportProjectSolution), MenuIcon = "Images/Save", MenuCategory = nameof(Resources.Save), MenuOrder = 21)] + [Shared] + [method: ImportingConstructor] + internal sealed class ExportProjectSolutionCommand( + AssemblyTreeModel assemblyTreeModel, + LanguageService languageService, + DockWorkspace dockWorkspace, + SettingsService settingsService) : SimpleCommand + { + public override bool CanExecute(object? parameter) + => ProjectExport.TryGetExportableAssemblies(assemblyTreeModel.SelectedItems, out _, out _); + + public override void Execute(object? parameter) + { + if (!ProjectExport.TryGetExportableAssemblies(assemblyTreeModel.SelectedItems, out var assemblies, out var solutionMode)) + return; + _ = ProjectExport.PromptAndExportAsync(assemblies, solutionMode, + languageService.CurrentLanguage, dockWorkspace, settingsService); + } + } + + [ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.GeneratePortable), MenuCategory = nameof(Resources.Save), MenuOrder = 22)] [Shared] sealed class GeneratePdbCommand : SimpleCommand { diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs new file mode 100644 index 000000000..ff7b45e3c --- /dev/null +++ b/ILSpy/Commands/ProjectExport.cs @@ -0,0 +1,120 @@ +// 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 global::Avalonia.Controls.ApplicationLifetimes; + +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.Docking; +using ILSpy.Languages; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.Views; + +namespace 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. + /// + 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. + /// + public static bool TryGetExportableAssemblies(IReadOnlyList? nodes, + out List assemblies, out bool solutionMode) + { + assemblies = new List(); + solutionMode = false; + if (nodes is not { Count: > 0 }) + return false; + if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) + return false; + assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList(); + solutionMode = assemblies.Count > 1; + return true; + } + + public static async Task PromptAndExportAsync(IReadOnlyList assemblies, + bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService) + { + var owner = (global::Avalonia.Application.Current?.ApplicationLifetime + as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (owner == null) + return; + + 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(); + try + { + var output = await dockWorkspace.RunWithCancellation(async token => { + var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, token) + .ConfigureAwait(false); + var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution }; + o.Write(result.StatusText); + o.WriteLine(); + if (result.Success && Directory.Exists(options.OutputDirectory)) + { + o.AddButton(null, ICSharpCode.ILSpy.Properties.Resources.OpenExplorer, + (_, _) => OpenFolder(options.OutputDirectory)); + o.WriteLine(); + } + return o; + }, ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution); + dockWorkspace.ShowText(output); + } + catch (OperationCanceledException) + { + // User cancelled — leave the previous tab content visible. + } + } + + static void OpenFolder(string path) + { + try + { + if (OperatingSystem.IsWindows()) + Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = false }); + else if (OperatingSystem.IsMacOS()) + Process.Start(new ProcessStartInfo("open", $"\"{path}\"") { UseShellExecute = false }); + else + Process.Start(new ProcessStartInfo("xdg-open", path) { UseShellExecute = false }); + } + catch + { + // Best-effort. + } + } + } +} diff --git a/ILSpy/Commands/ProjectExportOptions.cs b/ILSpy/Commands/ProjectExportOptions.cs new file mode 100644 index 000000000..4836d6320 --- /dev/null +++ b/ILSpy/Commands/ProjectExportOptions.cs @@ -0,0 +1,36 @@ +// 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. + +namespace 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). + /// + public sealed record ProjectExportOptions( + string OutputDirectory, + bool UseSdkStyleProjectFormat, + bool UseNestedDirectoriesForNamespaces, + bool RemoveDeadCode, + bool RemoveDeadStores, + bool UseDebugSymbols, + string? StrongNameKeyFile, + bool GeneratePdb, + bool EmbedSourceFilesInPdb); +} diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs new file mode 100644 index 000000000..287b5934a --- /dev/null +++ b/ILSpy/Commands/ProjectExporter.cs @@ -0,0 +1,167 @@ +// 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; +using ICSharpCode.Decompiler.DebugInfo; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.ILSpyX; + +using ILSpy.Languages; + +namespace 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. + /// + internal static class ProjectExporter + { + public static async Task ExportAsync( + IReadOnlyList assemblies, bool solutionMode, ProjectExportOptions options, + DecompilerSettings settingsClone, Language language, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(assemblies); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(settingsClone); + ArgumentNullException.ThrowIfNull(language); + + ApplyOverrides(settingsClone, options); + + if (solutionMode) + { + var solutionFilePath = Path.Combine(options.OutputDirectory, SolutionFileName(options.OutputDirectory)); + var solution = await SolutionWriter.CreateSolutionAsync( + solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile) + .ConfigureAwait(false); + + var report = new StringBuilder(solution.StatusText); + if (options.GeneratePdb && solution.Success) + { + await Task.Run(() => GeneratePdbs(assemblies, + a => Path.Combine(options.OutputDirectory, a.ShortName), settingsClone, options, report, ct), ct) + .ConfigureAwait(false); + } + return new SolutionExportResult(solution.Success, report.ToString()); + } + + return await Task.Run(() => ExportProject(assemblies[0], options, settingsClone, language, ct), ct) + .ConfigureAwait(false); + } + + static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options, + DecompilerSettings settingsClone, Language language, CancellationToken ct) + { + var report = new StringBuilder(); + bool success; + try + { + var decompileOptions = new DecompilationOptions(settingsClone) { + FullDecompilation = true, + EscapeInvalidIdentifiers = true, + CancellationToken = ct, + SaveAsProjectDirectory = options.OutputDirectory, + StrongNameKeyFile = options.StrongNameKeyFile, + }; + language.DecompileAssembly(assembly, new PlainTextOutput(new StringWriter()), decompileOptions); + report.AppendLine("Project written to " + options.OutputDirectory); + success = true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + report.AppendLine($"Failed to decompile the assembly '{assembly.FileName}':{Environment.NewLine}{e.Message}"); + success = false; + } + + if (options.GeneratePdb && success) + GeneratePdbs(new[] { assembly }, _ => options.OutputDirectory, settingsClone, options, report, ct); + + return new SolutionExportResult(success, report.ToString()); + } + + static void GeneratePdbs(IReadOnlyList assemblies, + Func projectDirectory, DecompilerSettings settingsClone, + ProjectExportOptions options, StringBuilder report, CancellationToken ct) + { + report.AppendLine(); + foreach (var assembly in assemblies) + { + ct.ThrowIfCancellationRequested(); + if (assembly.GetMetadataFileOrNull() is not PEFile file + || !PortablePdbWriter.HasCodeViewDebugDirectoryEntry(file)) + { + report.AppendLine($"Skipped PDB for '{assembly.ShortName}': no CodeView debug directory entry."); + continue; + } + + var pdbFileName = Path.Combine(projectDirectory(assembly), + WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, ".pdb")); + try + { + using var stream = new FileStream(pdbFileName, FileMode.Create, FileAccess.Write); + var resolver = assembly.GetAssemblyResolver(); + var decompiler = new CSharpDecompiler(file, resolver, settingsClone) { + CancellationToken = ct, + }; + new PortablePdbWriter { EmbedSourceFiles = options.EmbedSourceFilesInPdb } + .WritePdb(file, decompiler, settingsClone, stream); + report.AppendLine("Generated PDB: " + pdbFileName); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + report.AppendLine($"Failed to generate a PDB for '{assembly.ShortName}': {e.Message}"); + } + } + } + + static void ApplyOverrides(DecompilerSettings settings, ProjectExportOptions options) + { + settings.UseSdkStyleProjectFormat = options.UseSdkStyleProjectFormat; + settings.UseNestedDirectoriesForNamespaces = options.UseNestedDirectoriesForNamespaces; + settings.RemoveDeadCode = options.RemoveDeadCode; + settings.RemoveDeadStores = options.RemoveDeadStores; + settings.UseDebugSymbols = options.UseDebugSymbols; + } + + // The .sln is named after the chosen output folder, falling back to "Solution.sln". + static string SolutionFileName(string outputDirectory) + { + var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; + } + } +} diff --git a/ILSpy/DecompilationOptions.cs b/ILSpy/DecompilationOptions.cs index 4354c314d..c4056e25a 100644 --- a/ILSpy/DecompilationOptions.cs +++ b/ILSpy/DecompilationOptions.cs @@ -42,6 +42,15 @@ namespace ILSpy /// Escape invalid C# identifiers so the output compiles. public bool EscapeInvalidIdentifiers { get; set; } + /// + /// Path to a strong-name key (.snk) for the exported project. When set, the project + /// export copies the key next to the project file and emits an + /// <AssemblyOriginatorKeyFile>. Honoured only on the project-export path + /// ( set); ignored otherwise. The key file itself + /// lives on . + /// + public string? StrongNameKeyFile { get; set; } + /// /// Stop the IL-transform pipeline after this many steps. /// means "run all transforms". The Debug Steps pane sets this to the index of a diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs index b2ea0d043..4d66b321a 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -567,6 +567,10 @@ namespace ILSpy.Languages projectWriter: null, assemblyReferenceClassifier: null, debugInfoProvider: debugInfo); + // Strong-name signing is a project-writer concern carried on the WholeProjectDecompiler; + // surface the export dialog's choice here (null = unsigned, the default). + if (!string.IsNullOrEmpty(options.StrongNameKeyFile)) + decompiler.StrongNameKeyFile = options.StrongNameKeyFile; var projectFileName = System.IO.Path.Combine( targetDirectory, WholeProjectDecompiler.CleanUpFileName(module.Name, ProjectFileExtension)); diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index edc84f9e4..4d907d73b 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -1956,6 +1956,15 @@ namespace ICSharpCode.ILSpy.Properties { } } + /// + /// Looks up a localized string similar to Export Project/Solution.... + /// + public static string ExportProjectSolution { + get { + return ResourceManager.GetString("ExportProjectSolution", resourceCulture); + } + } + /// /// Looks up a localized string similar to Generate portable PDB. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 78a4ef137..712b1d12a 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -675,6 +675,9 @@ Are you sure you want to continue? Forward + + Export Project/Solution... + Generate portable PDB diff --git a/ILSpy/SolutionWriter.cs b/ILSpy/SolutionWriter.cs index 12fc30bc2..94096001b 100644 --- a/ILSpy/SolutionWriter.cs +++ b/ILSpy/SolutionWriter.cs @@ -56,26 +56,36 @@ namespace ILSpy /// or whitespace. /// Thrown when or /// is null. + /// Decompiler settings each project is decompiled with. When null, + /// each project uses the language's own defaults (preserves the quick "Save Code" path). + /// Optional .snk copied into every project and emitted + /// as <AssemblyOriginatorKeyFile>. public static Task CreateSolutionAsync(string solutionFilePath, Language language, IReadOnlyList assemblies, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + DecompilerSettings? settings = null, string? strongNameKeyFile = null) { if (string.IsNullOrWhiteSpace(solutionFilePath)) throw new ArgumentException("The solution file path cannot be null or empty.", nameof(solutionFilePath)); ArgumentNullException.ThrowIfNull(language); ArgumentNullException.ThrowIfNull(assemblies); - return new SolutionWriter(solutionFilePath).CreateSolutionAsync(assemblies, language, cancellationToken); + return new SolutionWriter(solutionFilePath, settings, strongNameKeyFile) + .CreateSolutionAsync(assemblies, language, cancellationToken); } readonly string solutionFilePath; readonly string solutionDirectory; + readonly DecompilerSettings? settings; + readonly string? strongNameKeyFile; readonly ConcurrentBag projects; readonly ConcurrentBag statusOutput; - SolutionWriter(string solutionFilePath) + SolutionWriter(string solutionFilePath, DecompilerSettings? settings, string? strongNameKeyFile) { this.solutionFilePath = solutionFilePath; + this.settings = settings; + this.strongNameKeyFile = strongNameKeyFile; solutionDirectory = Path.GetDirectoryName(solutionFilePath)!; statusOutput = new ConcurrentBag(); projects = new ConcurrentBag(); @@ -202,12 +212,12 @@ namespace ILSpy try { - var options = new DecompilationOptions { - FullDecompilation = true, - EscapeInvalidIdentifiers = true, - CancellationToken = ct, - SaveAsProjectDirectory = targetDirectory, - }; + var options = settings != null ? new DecompilationOptions(settings) : new DecompilationOptions(); + options.FullDecompilation = true; + options.EscapeInvalidIdentifiers = true; + options.CancellationToken = ct; + options.SaveAsProjectDirectory = targetDirectory; + options.StrongNameKeyFile = strongNameKeyFile; // The project-export path writes the .csproj into SaveAsProjectDirectory itself; the // ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here. diff --git a/ILSpy/Views/ExportProjectDialog.axaml b/ILSpy/Views/ExportProjectDialog.axaml new file mode 100644 index 000000000..f9a93b83e --- /dev/null +++ b/ILSpy/Views/ExportProjectDialog.axaml @@ -0,0 +1,58 @@ + + + +