diff --git a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
index 451fe4441..4bd486e92 100644
--- a/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
+++ b/ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
@@ -38,10 +38,12 @@ namespace ICSharpCode.ILSpy.Tests.Commands;
///
/// Every File -> Save Code path for an assembly runs behind a cancellable progress overlay, never a
-/// bare Task.Run: the .csproj export goes through
-/// (the Export Project command's frozen determinate-progress tab), and the single-file save goes through
-/// (the same overlay normal decompilation
-/// uses). These assert both paths write their output and surface a report/breadcrumb in a tab.
+/// bare Task.Run, and every one that decompiles a whole assembly shares the Export Project command's
+/// frozen determinate-progress tab: for a
+/// .csproj, for several assemblies as a .sln. The
+/// single-file save goes through (the
+/// same overlay normal decompilation uses). These assert each path writes its output and surfaces a
+/// report/breadcrumb in a tab.
///
[TestFixture]
public class SaveCodeProjectExportTests
@@ -83,6 +85,53 @@ public class SaveCodeProjectExportTests
}
}
+ [AvaloniaTest]
+ public async Task Save_Several_Assemblies_Exports_A_Solution_Through_The_Export_Project_Tab()
+ {
+ var (_, vm) = await TestHarness.BootAsync();
+ var assemblies = new[] {
+ await vm.OpenFixtureAsync("FixtureA"),
+ await vm.OpenFixtureAsync("FixtureB"),
+ };
+ var dock = vm.DockWorkspace;
+
+ var language = AppComposition.Current.GetExport()
+ .Languages.OfType().First();
+ var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings();
+
+ var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveSln_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ // A name the output folder does not imply: Save Code lets the user pick the .sln file itself,
+ // unlike the Export Project dialog, which derives the name from the chosen folder.
+ var solutionPath = Path.Combine(tempDir, "Picked.sln");
+ try
+ {
+ await ProjectExport.ExportSolutionAsync(assemblies, solutionPath, settings, language, dock);
+
+ File.Exists(solutionPath).Should().BeTrue(
+ "the solution must land on the exact path picked in Save Code, not one derived from the folder");
+ foreach (var a in assemblies)
+ {
+ Directory.EnumerateFiles(Path.Combine(tempDir, a.ShortName), "*.csproj").Should().HaveCount(1,
+ $"each exported assembly gets its own project ({a.ShortName})");
+ }
+
+ var exportTab = dock.Documents!.VisibleDockables!.OfType()
+ .Select(t => t.Content).OfType()
+ .FirstOrDefault(d => d.Title == string.Join(", ", assemblies.Select(a => a.Text)));
+ exportTab.Should().NotBeNull(
+ "a solution export tab is titled after the assemblies being exported -- the same however it was started (Save Code or Export Project)");
+ exportTab!.Text.Should().Contain("Created the Visual Studio Solution file",
+ "the export runs in its own progress tab and reports there");
+ }
+ finally
+ {
+ try
+ { Directory.Delete(tempDir, recursive: true); }
+ catch { /* best-effort */ }
+ }
+ }
+
[AvaloniaTest]
public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab()
{
diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs
index 016671924..d55610d5c 100644
--- a/ILSpy/Commands/FileCommands.cs
+++ b/ILSpy/Commands/FileCommands.cs
@@ -240,9 +240,9 @@ namespace ICSharpCode.ILSpy.Commands
{
// Several selected assemblies export a Visual Studio solution (one project each),
// matching the Save Code context-menu entry.
- if (SolutionExport.TryGetAssemblies(assemblyTreeModel.SelectedItems, out var assemblies))
+ if (ProjectExport.TryGetSolutionAssemblies(assemblyTreeModel.SelectedItems, out var assemblies))
{
- await SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace);
+ await ProjectExport.PromptAndExportSolutionAsync(assemblies, languageService.CurrentLanguage, dockWorkspace);
return;
}
diff --git a/ILSpy/Commands/ProjectExport.cs b/ILSpy/Commands/ProjectExport.cs
index 9bb892e36..d27906ce9 100644
--- a/ILSpy/Commands/ProjectExport.cs
+++ b/ILSpy/Commands/ProjectExport.cs
@@ -26,6 +26,7 @@ using System.Threading.Tasks;
using global::Avalonia.Controls.ApplicationLifetimes;
using ICSharpCode.Decompiler;
+using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
@@ -40,10 +41,18 @@ using ICSharpCode.ILSpy.Views;
namespace ICSharpCode.ILSpy.Commands
{
///
- /// The shared launcher behind the dedicated "Export Project/Solution..." entry (File menu +
- /// assembly context menu): recognises an exportable selection, shows
- /// , then runs on a settings
- /// clone behind the tab's cancellable progress UI and reports into the active decompiler tab.
+ /// The shared launcher for every flow that decompiles whole assemblies to disk. All of them
+ /// recognise their selection with and run
+ /// on a settings clone behind a frozen progress tab
+ /// (), differing only in how the target and options are chosen:
+ ///
+ /// - "Export Project/Solution..." (File menu + assembly context menu) asks
+ /// for an output folder and per-run overrides.
+ /// - Save Code on one assembly, with a project extension picked in the file dialog, exports
+ /// that project with the live settings ().
+ /// - Save Code on several assemblies exports a solution to a picked .sln path, again
+ /// with the live settings ().
+ ///
///
internal static class ProjectExport
{
@@ -65,6 +74,14 @@ namespace ICSharpCode.ILSpy.Commands
return true;
}
+ ///
+ /// True when is the selection shape Save Code maps onto a solution:
+ /// several assembly nodes that all loaded as valid assemblies.
+ ///
+ public static bool TryGetSolutionAssemblies(IReadOnlyList? nodes,
+ out List assemblies)
+ => TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode;
+
public static async Task PromptAndExportAsync(IReadOnlyList assemblies,
bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService)
{
@@ -82,13 +99,8 @@ namespace ICSharpCode.ILSpy.Commands
}
///
- /// Exports a single assembly as a decompiled project into , using
- /// the current decompiler settings (no export-dialog overrides, no PDB, no strong-name key). This is
- /// the File -> Save Code -> .csproj path: it reuses the same +
- /// frozen-progress-tab machinery as the Export Project command, so a large assembly reports real
- /// progress and can be cancelled, instead of running silently. The tab is titled the same way as the
- /// Export Project command (see ) so the same operation reads the same
- /// however it was started.
+ /// Exports a single assembly as a decompiled project into .
+ /// This is the File -> Save Code path for a project extension.
///
public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string outputDirectory,
DecompilerSettings settings, Language language, DockWorkspace dockWorkspace)
@@ -97,9 +109,52 @@ namespace ICSharpCode.ILSpy.Commands
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(dockWorkspace);
- // Mirror the live settings into the options so ProjectExporter.ApplyOverrides is a no-op and the
- // output matches the plain "Save Code" behaviour exactly, just with progress now surfaced.
- var options = new ProjectExportOptions(
+ return RunExportAsync(new List { assembly }, solutionMode: false,
+ OptionsFrom(settings, outputDirectory), settings.Clone(), language, dockWorkspace);
+ }
+
+ ///
+ /// Prompts for a target .sln file and exports into it, one
+ /// decompiled project each. This is the File -> Save Code path for a multi-assembly selection.
+ /// Does nothing if the user cancels the picker.
+ ///
+ public static async Task PromptAndExportSolutionAsync(IReadOnlyList assemblies,
+ Language language, DockWorkspace dockWorkspace)
+ {
+ var path = await FilePickers.SaveAsync(
+ Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln",
+ Resources._SaveCode).ConfigureAwait(true);
+ if (string.IsNullOrEmpty(path))
+ return;
+
+ var settings = AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings()
+ ?? new DecompilerSettings();
+ await ExportSolutionAsync(assemblies, path, settings, language, dockWorkspace).ConfigureAwait(true);
+ }
+
+ ///
+ /// Exports as a solution written to ,
+ /// one decompiled project each. Public so tests (and scripted callers) can bypass the file picker.
+ ///
+ public static Task ExportSolutionAsync(IReadOnlyList assemblies, string solutionPath,
+ DecompilerSettings settings, Language language, DockWorkspace dockWorkspace)
+ {
+ ArgumentNullException.ThrowIfNull(assemblies);
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(dockWorkspace);
+
+ var options = OptionsFrom(settings, Path.GetDirectoryName(solutionPath) ?? string.Empty,
+ Path.GetFileName(solutionPath));
+ return RunExportAsync(assemblies, solutionMode: true, options, settings.Clone(), language, dockWorkspace);
+ }
+
+ // The Save Code paths take the settings as they stand instead of asking for per-run overrides, so
+ // mirror those settings into the options: ProjectExporter.ApplyOverrides then leaves the settings
+ // clone alone and the output matches a plain decompile, only with progress surfaced. No PDB and no
+ // strong-name key either -- both are dialog-only features.
+ static ProjectExportOptions OptionsFrom(DecompilerSettings settings, string outputDirectory,
+ string? solutionFileName = null)
+ => new(
OutputDirectory: outputDirectory,
UseSdkStyleProjectFormat: settings.UseSdkStyleProjectFormat,
UseNestedDirectoriesForNamespaces: settings.UseNestedDirectoriesForNamespaces,
@@ -108,24 +163,22 @@ namespace ICSharpCode.ILSpy.Commands
UseDebugSymbols: settings.UseDebugSymbols,
StrongNameKeyFile: null,
GeneratePdb: false,
- EmbedSourceFilesInPdb: false);
- return RunExportAsync(new List { assembly }, solutionMode: false, options,
- settings.Clone(), language, dockWorkspace);
- }
+ EmbedSourceFilesInPdb: false,
+ SolutionFileName: solutionFileName);
// Runs the export behind a dedicated frozen tab (so browsing the tree while it runs can't cancel it)
- // and reports the result there, with an Open-folder button on success. Shared by the Export Project
- // command and the Save Code -> .csproj path. The tab is titled after the assemblies being exported,
- // joining their full tree-node labels the same way DecompilerTabPageModel.ComposeBaseTitle titles a
- // multi-node decompile tab -- so a single-assembly export reads as that assembly and a solution
- // export as its members, ellipsised on the tab with the full list shown as a tooltip.
+ // and reports the result there, with an Open-folder button on success. The tab is titled after the
+ // assemblies being exported, joining their full tree-node labels the same way
+ // DecompilerTabPageModel.ComposeBaseTitle titles a multi-node decompile tab -- so a single-assembly
+ // export reads as that assembly and a solution export as its members, ellipsised on the tab with the
+ // full list shown as a tooltip.
static Task RunExportAsync(IReadOnlyList assemblies, bool solutionMode,
ProjectExportOptions options, DecompilerSettings settingsClone, Language language,
DockWorkspace dockWorkspace)
{
var title = assemblies.Count > 0
? string.Join(", ", assemblies.Select(a => a.Text))
- : ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution;
+ : Resources.ExportProjectSolution;
return dockWorkspace.RunInNewTabAsync(title, async (token, progress) => {
var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, progress, token)
.ConfigureAwait(false);
diff --git a/ILSpy/Commands/ProjectExportOptions.cs b/ILSpy/Commands/ProjectExportOptions.cs
index 4f504bf0b..cbade909d 100644
--- a/ILSpy/Commands/ProjectExportOptions.cs
+++ b/ILSpy/Commands/ProjectExportOptions.cs
@@ -19,10 +19,16 @@
namespace ICSharpCode.ILSpy.Commands
{
///
- /// The configuration chosen in the Export Project/Solution dialog and consumed by
- /// . The format/decompiler flags are applied onto a clone of the
- /// live decompiler settings (never the persisted instance).
+ /// The configuration for one export run, consumed by : chosen in the
+ /// Export Project/Solution dialog, or mirrored off the live settings by the Save Code paths. The
+ /// format/decompiler flags are applied onto a clone of the live decompiler settings (never the
+ /// persisted instance).
///
+ ///
+ /// In solution mode, the name of the .sln to write inside .
+ /// null names it after that directory, which is what the dialog wants (it only asks for a
+ /// folder); Save Code sets it, because there the user picks the solution file itself.
+ ///
public sealed record ProjectExportOptions(
string OutputDirectory,
bool UseSdkStyleProjectFormat,
@@ -32,5 +38,6 @@ namespace ICSharpCode.ILSpy.Commands
bool UseDebugSymbols,
string? StrongNameKeyFile,
bool GeneratePdb,
- bool EmbedSourceFilesInPdb);
+ bool EmbedSourceFilesInPdb,
+ string? SolutionFileName = null);
}
diff --git a/ILSpy/Commands/ProjectExporter.cs b/ILSpy/Commands/ProjectExporter.cs
index e5309c386..bdff0f6c1 100644
--- a/ILSpy/Commands/ProjectExporter.cs
+++ b/ILSpy/Commands/ProjectExporter.cs
@@ -57,7 +57,8 @@ namespace ICSharpCode.ILSpy.Commands
if (solutionMode)
{
- var solutionFilePath = Path.Combine(options.OutputDirectory, SolutionFileName(options.OutputDirectory));
+ var solutionFilePath = Path.Combine(options.OutputDirectory,
+ options.SolutionFileName ?? SolutionFileNameFor(options.OutputDirectory));
var solution = await SolutionWriter.CreateSolutionAsync(
solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile, progress)
.ConfigureAwait(false);
@@ -159,8 +160,9 @@ namespace ICSharpCode.ILSpy.Commands
settings.UseDebugSymbols = options.UseDebugSymbols;
}
- // The .sln is named after the chosen output folder, falling back to "Solution.sln".
- static string SolutionFileName(string outputDirectory)
+ // The default .sln name when the caller does not supply one: after the chosen output folder,
+ // falling back to "Solution.sln".
+ static string SolutionFileNameFor(string outputDirectory)
{
var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln";
diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
index f95399792..a1f728a4c 100644
--- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs
+++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
@@ -17,7 +17,6 @@
// DEALINGS IN THE SOFTWARE.
using System.Composition;
-using System.Linq;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX.TreeView;
@@ -35,7 +34,7 @@ namespace ICSharpCode.ILSpy.Commands
/// AssemblyTreeNode drives project / single-file selection, ResourceTreeNode
/// writes raw bytes, every other node falls through to the generic single-file decompile.
/// - Several selected assemblies export a Visual Studio solution (one decompiled project
- /// each) via .
+ /// each) via .
///
///
[ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 300)]
@@ -62,9 +61,9 @@ namespace ICSharpCode.ILSpy.Commands
if (nodes is not { Length: > 0 })
return;
- if (SolutionExport.TryGetAssemblies(nodes, out var assemblies))
+ if (ProjectExport.TryGetSolutionAssemblies(nodes, out var assemblies))
{
- SolutionExport.PromptAndExportAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions();
+ ProjectExport.PromptAndExportSolutionAsync(assemblies, languageService.CurrentLanguage, dockWorkspace).HandleExceptions();
return;
}
@@ -84,7 +83,7 @@ namespace ICSharpCode.ILSpy.Commands
return false;
if (selectedNodes.Length == 1)
return selectedNodes[0] is ILSpyTreeNode;
- return SolutionExport.TryGetAssemblies(selectedNodes, out _);
+ return ProjectExport.TryGetSolutionAssemblies(selectedNodes, out _);
}
}
}
diff --git a/ILSpy/Commands/SolutionExport.cs b/ILSpy/Commands/SolutionExport.cs
deleted file mode 100644
index 4dce21bd1..000000000
--- a/ILSpy/Commands/SolutionExport.cs
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy of this
-// software and associated documentation files (the "Software"), to deal in the Software
-// without restriction, including without limitation the rights to use, copy, modify, merge,
-// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
-// to whom the Software is furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in all copies or
-// substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
-// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
-// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
-// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
-// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using System.Threading.Tasks;
-
-using ICSharpCode.ILSpy.Properties;
-using ICSharpCode.ILSpyX;
-using ICSharpCode.ILSpyX.TreeView;
-
-using ICSharpCode.ILSpy.Docking;
-using ICSharpCode.ILSpy.Languages;
-using ICSharpCode.ILSpy.TextView;
-using ICSharpCode.ILSpy.TreeNodes;
-using ICSharpCode.ILSpy.Util;
-
-namespace ICSharpCode.ILSpy.Commands
-{
- ///
- /// The shared "export several assemblies as a Visual Studio solution" flow behind both the
- /// File → Save Code command and the Save Code context-menu entry: it recognises a
- /// solution-eligible selection, prompts for the .sln path, runs
- /// behind the tab's cancellable progress UI, and surfaces the
- /// status report in the active decompiler tab.
- ///
- internal static class SolutionExport
- {
- ///
- /// True when is several assembly nodes that all loaded as valid
- /// assemblies — the only selection shape that maps onto a multi-project solution.
- ///
- public static bool TryGetAssemblies(IReadOnlyList? nodes, out List assemblies)
- {
- assemblies = new List();
- if (nodes is not { Count: > 1 })
- return false;
- if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }))
- return false;
- assemblies = nodes.OfType().Select(n => n.LoadedAssembly).ToList();
- return true;
- }
-
- ///
- /// Prompts for a target .sln file and exports into it,
- /// one decompiled project each. The export runs in its own frozen tab so browsing the tree
- /// can't cancel it, and the status report lands there. Does nothing if the user cancels the picker.
- ///
- public static async Task PromptAndExportAsync(IReadOnlyList assemblies,
- Language language, DockWorkspace dockWorkspace)
- {
- var path = await FilePickers.SaveAsync(
- Resources.VisualStudioSolutionFileSlnAllFiles, "Solution.sln", Resources._SaveCode)
- .ConfigureAwait(true);
- if (string.IsNullOrEmpty(path))
- return;
-
- // Snapshot the user's current decompiler settings for the whole export, like the
- // per-project export path does.
- var settings = AppEnv.AppComposition.TryGetExport()?.CreateEffectiveDecompilerSettings()
- ?? new ICSharpCode.Decompiler.DecompilerSettings();
-
- // Run in a dedicated frozen tab so browsing the tree while the export runs can't cancel it.
- await dockWorkspace.RunInNewTabAsync("Exporting solution", async (token, progress) => {
- var result = await SolutionWriter.CreateSolutionAsync(path, language, assemblies, token,
- settings, strongNameKeyFile: null, progress: progress)
- .ConfigureAwait(false);
- var o = new AvaloniaEditTextOutput { Title = Resources._SaveCode };
- o.Write(result.StatusText);
- o.WriteLine();
- if (result.Success && Path.GetDirectoryName(path) is { Length: > 0 } directory)
- {
- o.AddOpenFolderButton(directory);
- }
- return o;
- }).ConfigureAwait(true);
- }
-
- }
-}