diff --git a/ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs b/ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs
new file mode 100644
index 000000000..f0e8cde30
--- /dev/null
+++ b/ILSpy.Tests/ContextMenus/SaveCodeSolutionContextMenuTests.cs
@@ -0,0 +1,100 @@
+// 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.TextView;
+using ILSpy.TreeNodes;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests;
+
+///
+/// Visibility/enablement rules for "Save Code": a single node always qualifies (its own
+/// Save override decides the format), several valid assemblies qualify (the solution
+/// export path), and any other multi-selection does not.
+///
+[TestFixture]
+public class SaveCodeSolutionContextMenuTests
+{
+ static IContextMenuEntry Entry()
+ => AppComposition.Current.GetExport().GetEntry(nameof(Resources._SaveCode));
+
+ static TextViewContext Context(params SharpTreeNode[] nodes)
+ => new() { SelectedTreeNodes = nodes };
+
+ [AvaloniaTest]
+ public async Task Single_Node_Is_Visible()
+ {
+ var (_, vm) = await TestHarness.BootAsync(3);
+ var assembly = vm.AssemblyTreeModel.FindNode("System.Linq");
+ Assert.That(assembly, Is.Not.Null);
+
+ var entry = Entry();
+ entry.IsVisible(Context(assembly!)).Should().BeTrue("a single node delegates to its own Save override");
+ entry.IsEnabled(Context(assembly!)).Should().BeTrue();
+ }
+
+ [AvaloniaTest]
+ public async Task Multiple_Valid_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 the solution export path");
+ }
+
+ [AvaloniaTest]
+ public async Task Mixed_Multi_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(
+ "a mixed assembly + type selection has no well-defined Save Code action");
+ }
+
+ [AvaloniaTest]
+ public async Task Empty_Selection_Is_Hidden()
+ {
+ await TestHarness.BootAsync(1);
+ Entry().IsVisible(Context()).Should().BeFalse();
+ }
+}
diff --git a/ILSpy.Tests/Languages/SolutionExportTests.cs b/ILSpy.Tests/Languages/SolutionExportTests.cs
new file mode 100644
index 000000000..102b58c48
--- /dev/null
+++ b/ILSpy.Tests/Languages/SolutionExportTests.cs
@@ -0,0 +1,126 @@
+// 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.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ILSpy.AppEnv;
+using ILSpy.Languages;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Languages;
+
+///
+/// End-to-end smoke test of the multi-assembly solution (.sln) export path that backs the
+/// "Save Code" entry when several assemblies are selected. Drives
+/// directly (no file picker)
+/// and asserts a solution file plus one project per assembly is produced.
+///
+[TestFixture]
+public class SolutionExportTests
+{
+ [AvaloniaTest]
+ public async Task CreateSolution_Writes_Sln_And_Per_Assembly_Projects()
+ {
+ var (_, vm) = await TestHarness.BootAsync(3);
+
+ // CoreLib is too big for a smoke test; the Uri and System.Linq reference assemblies
+ // the headless fixture seeds are both manageable full-project decompiles.
+ 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().HaveCountGreaterThanOrEqualTo(2, "a solution needs at least two distinct assemblies");
+
+ foreach (var a in assemblies)
+ await a.GetLoadResultAsync();
+
+ var language = AppComposition.Current.GetExport()
+ .Languages.OfType().First();
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySln_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var slnPath = Path.Combine(tempDir, "Solution.sln");
+ try
+ {
+ var result = await global::ILSpy.SolutionWriter.CreateSolutionAsync(slnPath, language, assemblies);
+
+ result.Success.Should().BeTrue(
+ "the solution export should succeed for valid assemblies. Status:\n" + result.StatusText);
+ File.Exists(slnPath).Should().BeTrue("the .sln file must be written to the chosen path");
+
+ foreach (var a in assemblies)
+ {
+ var projectDir = Path.Combine(tempDir, a.ShortName);
+ Directory.EnumerateFiles(projectDir, "*.csproj").Should().HaveCount(1,
+ $"each assembly gets its own subdirectory with one project file ({a.ShortName})");
+ Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(
+ $"the decompiled project for {a.ShortName} must contain at least one source file");
+ }
+
+ result.StatusText.Should().Contain("Created the Visual Studio Solution file",
+ "a successful run reports the solution-written breadcrumb");
+ }
+ finally
+ {
+ try
+ { Directory.Delete(tempDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+
+ [AvaloniaTest]
+ public async Task CreateSolution_Aborts_On_Duplicate_Assembly_Names()
+ {
+ var (_, vm) = await TestHarness.BootAsync(3);
+
+ var assembly = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies()
+ .First(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName);
+ await assembly.GetLoadResultAsync();
+
+ var language = AppComposition.Current.GetExport()
+ .Languages.OfType().First();
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySlnDup_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ var slnPath = Path.Combine(tempDir, "Solution.sln");
+ try
+ {
+ // The same assembly twice collides on ShortName: the writer must refuse rather than
+ // clobber one project directory with another.
+ var result = await global::ILSpy.SolutionWriter.CreateSolutionAsync(
+ slnPath, language, new[] { assembly, assembly });
+
+ result.Success.Should().BeFalse("duplicate assembly names cannot produce a valid solution");
+ result.StatusText.Should().Contain("Duplicate assembly names");
+ File.Exists(slnPath).Should().BeFalse("no solution file is written when the export aborts");
+ }
+ finally
+ {
+ try
+ { Directory.Delete(tempDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+}
diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs
index 857d805b3..8ffc220be 100644
--- a/ILSpy/Commands/FileCommands.cs
+++ b/ILSpy/Commands/FileCommands.cs
@@ -202,13 +202,22 @@ namespace ILSpy.Commands
[method: ImportingConstructor]
internal sealed class SaveCommand(
AssemblyTreeModel assemblyTreeModel,
- LanguageService languageService) : SimpleCommand
+ LanguageService languageService,
+ DockWorkspace dockWorkspace) : SimpleCommand
{
public override bool CanExecute(object? parameter)
=> assemblyTreeModel.SelectedItem is ILSpyTreeNode;
public override async void Execute(object? parameter)
{
+ // Several selected assemblies export a Visual Studio solution (one project each),
+ // matching the Save Code context-menu entry.
+ if (SolutionExport.TryGetAssemblies(assemblyTreeModel.SelectedItems, out var assemblies))
+ {
+ await SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace);
+ return;
+ }
+
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node)
return;
// Resource nodes (and any future override) handle their own save format and dialog;
diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
index 7021abf44..c202ee480 100644
--- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs
+++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
@@ -17,40 +17,71 @@
// DEALINGS IN THE SOFTWARE.
using System.Composition;
+using System.Linq;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX.TreeView;
+using ILSpy.Docking;
+using ILSpy.Languages;
using ILSpy.TreeNodes;
namespace ILSpy.Commands
{
///
- /// Right-click → "Save Code". Delegates to the selected node's
- /// override so the context-menu entry and File →
- /// Save Code take exactly the same path: AssemblyTreeNode drives project /
- /// single-file selection, ResourceTreeNode writes raw bytes, every other
- /// node falls through to the generic single-file decompile in the existing
- /// FileCommands.SaveCommand.
+ /// Right-click → "Save Code". Mirrors the WPF entry's two modes:
+ ///
+ /// - A single node delegates to its override —
+ /// AssemblyTreeNode drives project / single-file selection, ResourceTreeNode
+ /// writes raw bytes, every other node falls through to the generic single-file decompile.
+ /// - Several selected assemblies export a Visual Studio solution (one decompiled project
+ /// each) via .
+ ///
///
[ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 300)]
[Shared]
public sealed class SaveCodeContextMenuEntry : IContextMenuEntry
{
- public bool IsVisible(TextViewContext context) => GetTarget(context) != null;
+ readonly LanguageService languageService;
+ readonly DockWorkspace dockWorkspace;
- public bool IsEnabled(TextViewContext context) => GetTarget(context) != null;
+ [ImportingConstructor]
+ public SaveCodeContextMenuEntry(LanguageService languageService, DockWorkspace dockWorkspace)
+ {
+ this.languageService = languageService;
+ this.dockWorkspace = dockWorkspace;
+ }
+
+ public bool IsVisible(TextViewContext context) => CanExecute(context.SelectedTreeNodes);
+
+ public bool IsEnabled(TextViewContext context) => CanExecute(context.SelectedTreeNodes);
public void Execute(TextViewContext context)
{
- GetTarget(context)?.Save();
+ var nodes = context.SelectedTreeNodes;
+ if (nodes is not { Length: > 0 })
+ return;
+
+ if (SolutionExport.TryGetAssemblies(nodes, out var assemblies))
+ {
+ _ = SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace);
+ return;
+ }
+
+ if (nodes.Length == 1)
+ (nodes[0] as ILSpyTreeNode)?.Save();
}
- static ILSpyTreeNode? GetTarget(TextViewContext context)
+ // Visible when exactly one ILSpyTreeNode is selected (the single-node save path), or when
+ // several valid assemblies are selected (the solution-export path). Mixed or other
+ // multi-selections have no well-defined "save code" meaning.
+ static bool CanExecute(SharpTreeNode[]? selectedNodes)
{
- if (context.SelectedTreeNodes is not { Length: 1 } nodes)
- return null;
- return nodes[0] as ILSpyTreeNode;
+ if (selectedNodes is not { Length: > 0 })
+ return false;
+ if (selectedNodes.Length == 1)
+ return selectedNodes[0] is ILSpyTreeNode;
+ return SolutionExport.TryGetAssemblies(selectedNodes, out _);
}
}
}
diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs
new file mode 100644
index 000000000..049554068
--- /dev/null
+++ b/ILSpy/Commands/SolutionExport.cs
@@ -0,0 +1,115 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpyX;
+using ICSharpCode.ILSpyX.TreeView;
+
+using ILSpy.Docking;
+using ILSpy.Languages;
+using ILSpy.TextView;
+using ILSpy.TreeNodes;
+
+namespace ILSpy.Commands
+{
+ ///
+ /// The shared "export several assemblies as a Visual Studio solution" flow behind both the
+ /// File → Save Code command and the Save Code context-menu entry: it recognises a
+ /// solution-eligible selection, prompts for the .sln path, runs
+ /// behind the tab's cancellable progress UI, and surfaces the
+ /// status report in the active decompiler tab.
+ ///
+ internal static class SolutionExport
+ {
+ ///
+ /// True when is several assembly nodes that all loaded as valid
+ /// assemblies — the only selection shape that maps onto a multi-project solution.
+ ///
+ public static bool TryGetAssemblies(IReadOnlyList? nodes, out List assemblies)
+ {
+ assemblies = new List();
+ if (nodes is not { Count: > 1 })
+ return false;
+ if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }))
+ return false;
+ assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList();
+ return true;
+ }
+
+ ///
+ /// Prompts for a target .sln file and exports into it,
+ /// one decompiled project each. The status report lands in the active decompiler tab, where
+ /// the user is already looking. Does nothing if the user cancels the picker.
+ ///
+ public static async Task PromptAndExportAsync(IReadOnlyList assemblies,
+ Language language, DockWorkspace dockWorkspace)
+ {
+ var path = await FilePickers.SaveAsync(
+ Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", Resources._SaveCode)
+ .ConfigureAwait(true);
+ if (string.IsNullOrEmpty(path))
+ return;
+
+ try
+ {
+ var output = await dockWorkspace.RunWithCancellation(async token => {
+ var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token)
+ .ConfigureAwait(false);
+ var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode };
+ o.Write(result.StatusText);
+ o.WriteLine();
+ if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory)
+ {
+ o.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(directory));
+ o.WriteLine();
+ }
+ return o;
+ }, "Exporting solution");
+ 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/SolutionWriter.cs b/ILSpy/SolutionWriter.cs
new file mode 100644
index 000000000..12fc30bc2
--- /dev/null
+++ b/ILSpy/SolutionWriter.cs
@@ -0,0 +1,243 @@
+// Copyright (c) 2011 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.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.Solution;
+using ICSharpCode.ILSpyX;
+
+using ILSpy.Languages;
+
+namespace ILSpy
+{
+ ///
+ /// The outcome of a run: whether a complete
+ /// solution was produced and the human-readable status report (the same breadcrumb the WPF
+ /// version printed into the decompiler text view).
+ ///
+ public sealed record SolutionExportResult(bool Success, string StatusText);
+
+ ///
+ /// Creates a Visual Studio solution containing one decompiled project per assembly. The
+ /// solution directory must be empty or non-existent. UI-agnostic: callers supply an explicit
+ /// target path (the "Save Code" entry picks it via a file dialog) and surface
+ /// however they like.
+ ///
+ internal sealed class SolutionWriter
+ {
+ ///
+ /// Decompiles every assembly in into its own project under the
+ /// directory of and writes the solution file itself.
+ ///
+ /// Thrown when is null
+ /// or whitespace.
+ /// Thrown when or
+ /// is null.
+ public static Task CreateSolutionAsync(string solutionFilePath,
+ Language language, IReadOnlyList assemblies,
+ CancellationToken cancellationToken = default)
+ {
+ 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);
+ }
+
+ readonly string solutionFilePath;
+ readonly string solutionDirectory;
+ readonly ConcurrentBag projects;
+ readonly ConcurrentBag statusOutput;
+
+ SolutionWriter(string solutionFilePath)
+ {
+ this.solutionFilePath = solutionFilePath;
+ solutionDirectory = Path.GetDirectoryName(solutionFilePath)!;
+ statusOutput = new ConcurrentBag();
+ projects = new ConcurrentBag();
+ }
+
+ async Task CreateSolutionAsync(IReadOnlyList allAssemblies,
+ Language language, CancellationToken ct)
+ {
+ var report = new StringBuilder();
+
+ // Two assemblies that share a short name would decompile into the same project directory;
+ // refuse rather than have one clobber the other.
+ var assembliesByShortName = allAssemblies.GroupBy(a => a.ShortName).ToDictionary(g => g.Key, g => g.ToList());
+ bool first = true;
+ bool abort = false;
+ foreach (var (_, assemblies) in assembliesByShortName)
+ {
+ if (assemblies.Count == 1)
+ continue;
+ if (first)
+ {
+ report.AppendLine("Duplicate assembly names selected, cannot generate a solution:");
+ abort = true;
+ first = false;
+ }
+ report.AppendLine("- " + assemblies[0].Text + " conflicts with "
+ + string.Join(", ", assemblies.Skip(1).Select(a => a.Text)));
+ }
+
+ if (abort)
+ return new SolutionExportResult(false, report.ToString());
+
+ try
+ {
+ // An explicit enumerable partitioner avoids Parallel.ForEach's list special-casing,
+ // whose static partitioning is inefficient when assemblies decompile at different speeds.
+ await Task.Run(() => System.Threading.Tasks.Parallel.ForEach(Partitioner.Create(allAssemblies),
+ new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, CancellationToken = ct },
+ item => WriteProject(item, language, solutionDirectory, ct)))
+ .ConfigureAwait(false);
+
+ if (projects.Count == 0)
+ {
+ report.AppendLine();
+ report.AppendLine("Solution could not be created, because none of the selected assemblies could be decompiled into a project.");
+ return new SolutionExportResult(false, report.ToString());
+ }
+
+ await Task.Run(() => SolutionCreator.WriteSolutionFile(solutionFilePath, projects.ToList()), ct)
+ .ConfigureAwait(false);
+ }
+ catch (AggregateException ae)
+ {
+ if (ae.Flatten().InnerExceptions.All(e => e is OperationCanceledException))
+ {
+ report.AppendLine();
+ report.AppendLine("Generation was cancelled.");
+ return new SolutionExportResult(false, report.ToString());
+ }
+
+ report.AppendLine();
+ report.AppendLine("Failed to generate the Visual Studio Solution. Errors:");
+ ae.Handle(e => {
+ report.AppendLine(e.Message);
+ return true;
+ });
+ return new SolutionExportResult(false, report.ToString());
+ }
+ catch (OperationCanceledException)
+ {
+ report.AppendLine();
+ report.AppendLine("Generation was cancelled.");
+ return new SolutionExportResult(false, report.ToString());
+ }
+
+ foreach (var item in statusOutput)
+ report.AppendLine(item);
+
+ // statusOutput only collects per-assembly failures; an empty bag means every project built.
+ if (statusOutput.Count != 0)
+ return new SolutionExportResult(false, report.ToString());
+
+ report.AppendLine("Successfully decompiled the following assemblies into Visual Studio projects:");
+ foreach (var n in allAssemblies)
+ report.AppendLine(n.Text.ToString());
+ report.AppendLine();
+ if (allAssemblies.Count == projects.Count)
+ report.AppendLine("Created the Visual Studio Solution file.");
+
+ return new SolutionExportResult(true, report.ToString());
+ }
+
+ void WriteProject(LoadedAssembly loadedAssembly, Language language, string targetDirectory, CancellationToken ct)
+ {
+ targetDirectory = Path.Combine(targetDirectory, loadedAssembly.ShortName);
+
+ if (language.ProjectFileExtension == null)
+ {
+ statusOutput.Add("-------------");
+ statusOutput.Add($"Language '{language.Name}' does not support exporting assemblies as projects!");
+ return;
+ }
+
+ if (File.Exists(targetDirectory))
+ {
+ statusOutput.Add("-------------");
+ statusOutput.Add($"Failed to create a directory '{targetDirectory}':{Environment.NewLine}A file with the same name already exists!");
+ return;
+ }
+
+ if (!Directory.Exists(targetDirectory))
+ {
+ try
+ {
+ Directory.CreateDirectory(targetDirectory);
+ }
+ catch (Exception e)
+ {
+ statusOutput.Add("-------------");
+ statusOutput.Add($"Failed to create a directory '{targetDirectory}':{Environment.NewLine}{e}");
+ return;
+ }
+ }
+
+ try
+ {
+ var options = new DecompilationOptions {
+ FullDecompilation = true,
+ EscapeInvalidIdentifiers = true,
+ CancellationToken = ct,
+ SaveAsProjectDirectory = targetDirectory,
+ };
+
+ // The project-export path writes the .csproj into SaveAsProjectDirectory itself; the
+ // ITextOutput only receives a "Project written to ..." breadcrumb, which we discard here.
+ var projectInfo = language.DecompileAssembly(loadedAssembly, new PlainTextOutput(new StringWriter()), options);
+ if (projectInfo != null)
+ {
+ // SolutionCreator.FixAllProjectReferences parses each project file off disk, so the
+ // ProjectItem must point at the .csproj the decompiler actually produced (its name is
+ // derived from the module name, not necessarily the assembly short name).
+ var projectFileName = Directory.EnumerateFiles(targetDirectory, "*" + language.ProjectFileExtension).FirstOrDefault()
+ ?? Path.Combine(targetDirectory, loadedAssembly.ShortName + language.ProjectFileExtension);
+ projects.Add(new ProjectItem(projectFileName, projectInfo.PlatformName, projectInfo.Guid, projectInfo.TypeGuid));
+ }
+ }
+ catch (NotSupportedException e)
+ {
+ statusOutput.Add("-------------");
+ statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e.Message}");
+ }
+ catch (PathTooLongException e)
+ {
+ statusOutput.Add("-------------");
+ statusOutput.Add(string.Format(ICSharpCode.ILSpy.Properties.Resources.ProjectExportPathTooLong, loadedAssembly.FileName)
+ + Environment.NewLine + Environment.NewLine + e.ToString());
+ }
+ catch (Exception e) when (e is not OperationCanceledException)
+ {
+ statusOutput.Add("-------------");
+ statusOutput.Add($"Failed to decompile the assembly '{loadedAssembly.FileName}':{Environment.NewLine}{e}");
+ }
+ }
+ }
+}