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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ILSpy/Views/ExportProjectDialog.axaml.cs b/ILSpy/Views/ExportProjectDialog.axaml.cs
new file mode 100644
index 000000000..072f5b442
--- /dev/null
+++ b/ILSpy/Views/ExportProjectDialog.axaml.cs
@@ -0,0 +1,202 @@
+// 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.Linq;
+
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Avalonia.Platform.Storage;
+
+using ICSharpCode.Decompiler.DebugInfo;
+using ICSharpCode.Decompiler.Metadata;
+using ICSharpCode.ILSpyX;
+
+using ILSpy.AppEnv;
+using ILSpy.Commands;
+
+namespace ILSpy.Views
+{
+ ///
+ /// One row of the export preview: the project that will be written for an assembly plus the
+ /// badges (invalid / duplicate-name / PDB-eligible) computed by
+ /// .
+ ///
+ public sealed record ProjectPreviewRow(
+ string ProjectName, string TargetSubdirectory,
+ bool IsValidAssembly, bool HasDuplicateShortName, bool IsPdbEligible)
+ {
+ public string Display => TargetSubdirectory is { Length: > 0 } sub && sub != "."
+ ? sub + " / " + ProjectName
+ : ProjectName;
+
+ public string BadgeText {
+ get {
+ var badges = new List();
+ if (!IsValidAssembly)
+ badges.Add("not a valid assembly");
+ if (HasDuplicateShortName)
+ badges.Add("duplicate name");
+ if (IsPdbEligible)
+ badges.Add("PDB");
+ return string.Join(" ", badges);
+ }
+ }
+ }
+
+ ///
+ /// Configuration dialog for the "Export Project/Solution" command. Adapts to the selection
+ /// (one valid assembly = project mode, several = solution mode), pre-fills format/decompiler
+ /// toggles from the live settings, previews the projects to be written, and returns a
+ /// (or null on cancel) via
+ /// . All export work happens in
+ /// ; this window only collects choices.
+ ///
+ public partial class ExportProjectDialog : Window
+ {
+ readonly bool solutionMode;
+ bool hasDuplicateConflict;
+
+ // Design-time / parameterless ctor for the XAML previewer.
+ public ExportProjectDialog() : this(TryResolveSettings(), System.Array.Empty(), false)
+ {
+ }
+
+ public ExportProjectDialog(SettingsService? settingsService,
+ IReadOnlyList assemblies, bool solutionMode)
+ {
+ InitializeComponent();
+ this.solutionMode = solutionMode;
+ Title = solutionMode ? "Export Solution" : "Export Project";
+
+ var rows = BuildPreviewRows(assemblies, solutionMode);
+ hasDuplicateConflict = solutionMode && rows.Any(r => r.HasDuplicateShortName);
+ PreviewList.ItemsSource = rows;
+ ConflictWarning.IsVisible = hasDuplicateConflict;
+
+ var settings = settingsService?.DecompilerSettings;
+ if (settings != null)
+ {
+ SdkStyleCheck.IsChecked = settings.UseSdkStyleProjectFormat;
+ NestedDirsCheck.IsChecked = settings.UseNestedDirectoriesForNamespaces;
+ RemoveDeadCodeCheck.IsChecked = settings.RemoveDeadCode;
+ RemoveDeadStoresCheck.IsChecked = settings.RemoveDeadStores;
+ UseDebugSymbolsCheck.IsChecked = settings.UseDebugSymbols;
+ }
+
+ BrowseOutputButton.Click += async (_, _) => {
+ var folder = await FilePickers.PickFolderAsync("Select the export output folder");
+ if (!string.IsNullOrEmpty(folder))
+ {
+ OutputBox.Text = folder;
+ UpdateExportEnabled();
+ }
+ };
+ BrowseKeyButton.Click += async (_, _) => await BrowseKeyFileAsync();
+
+ // Embedding source into the PDB only makes sense once a PDB is being generated; default it
+ // off in project mode (the .cs are already written to disk next to it).
+ GeneratePdbCheck.IsChecked = false;
+ EmbedSourceCheck.IsChecked = false;
+ GeneratePdbCheck.IsCheckedChanged += (_, _) =>
+ EmbedSourceCheck.IsEnabled = GeneratePdbCheck.IsChecked == true;
+
+ ExportButton.Click += (_, _) => Close(BuildOptions());
+ CancelButton.Click += (_, _) => Close(null);
+
+ UpdateExportEnabled();
+ }
+
+ ///
+ /// Computes one preview row per assembly: the project name + target subdirectory (solution
+ /// mode places each project in a ShortName subfolder; project mode writes into the
+ /// chosen folder directly) and the invalid / duplicate-name / PDB-eligible badges. Pure and
+ /// side-effect free so it can be unit-tested without the window.
+ ///
+ internal static IReadOnlyList BuildPreviewRows(
+ IReadOnlyList assemblies, bool solutionMode)
+ {
+ var duplicates = assemblies
+ .GroupBy(a => a.ShortName)
+ .Where(g => g.Count() > 1)
+ .Select(g => g.Key)
+ .ToHashSet();
+
+ var rows = new List(assemblies.Count);
+ foreach (var assembly in assemblies)
+ {
+ bool pdbEligible = false;
+ try
+ {
+ pdbEligible = assembly.GetMetadataFileOrNull() is PEFile file
+ && PortablePdbWriter.HasCodeViewDebugDirectoryEntry(file);
+ }
+ catch
+ {
+ pdbEligible = false;
+ }
+
+ rows.Add(new ProjectPreviewRow(
+ ProjectName: assembly.ShortName + ".csproj",
+ TargetSubdirectory: solutionMode ? assembly.ShortName : ".",
+ IsValidAssembly: assembly.IsLoadedAsValidAssembly,
+ HasDuplicateShortName: duplicates.Contains(assembly.ShortName),
+ IsPdbEligible: pdbEligible));
+ }
+ return rows;
+ }
+
+ ProjectExportOptions BuildOptions()
+ => new(
+ OutputBox.Text ?? string.Empty,
+ SdkStyleCheck.IsChecked == true,
+ NestedDirsCheck.IsChecked == true,
+ RemoveDeadCodeCheck.IsChecked == true,
+ RemoveDeadStoresCheck.IsChecked == true,
+ UseDebugSymbolsCheck.IsChecked == true,
+ string.IsNullOrEmpty(KeyFileBox.Text) ? null : KeyFileBox.Text,
+ GeneratePdbCheck.IsChecked == true,
+ EmbedSourceCheck.IsChecked == true);
+
+ void UpdateExportEnabled()
+ => ExportButton.IsEnabled = !string.IsNullOrEmpty(OutputBox.Text) && !hasDuplicateConflict;
+
+ async System.Threading.Tasks.Task BrowseKeyFileAsync()
+ {
+ var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions {
+ Title = "Select strong-name key file",
+ AllowMultiple = false,
+ FileTypeFilter = new[] {
+ new FilePickerFileType("Strong-name key (*.snk)") { Patterns = new[] { "*.snk" } },
+ FilePickerFileTypes.All,
+ },
+ });
+ var path = files.Count > 0 ? files[0].TryGetLocalPath() : null;
+ if (!string.IsNullOrEmpty(path))
+ KeyFileBox.Text = path;
+ }
+
+ static SettingsService? TryResolveSettings()
+ {
+ try
+ { return AppComposition.Current.GetExport(); }
+ catch
+ { return null; }
+ }
+ }
+}