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