Browse Source

Fold the solution export into the shared project-export path

Save Code on several assemblies had its own copy of the export flow: its
own selection matcher, its own frozen-tab runner, and a hard-coded
"Exporting solution" tab title -- so the same operation read differently
depending on whether it was started from Save Code or Export Project,
which titles the tab after the assemblies. Route it through ProjectExport
like the single-assembly path already is, leaving one runner and one
matcher behind every flow that decompiles whole assemblies to disk.

Save Code keeps letting the user name the .sln (the Export Project dialog
only asks for a folder and derives the name from it), so the export
options now carry an optional solution file name; unset means the old
folder-derived name.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3885/head
Siegfried Pammer 2 months ago committed by Siegfried Pammer
parent
commit
d978ecf881
  1. 57
      ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs
  2. 4
      ILSpy/Commands/FileCommands.cs
  3. 101
      ILSpy/Commands/ProjectExport.cs
  4. 15
      ILSpy/Commands/ProjectExportOptions.cs
  5. 8
      ILSpy/Commands/ProjectExporter.cs
  6. 9
      ILSpy/Commands/SaveCodeContextMenuEntry.cs
  7. 98
      ILSpy/Commands/SolutionExport.cs

57
ILSpy.Tests/Commands/SaveCodeProjectExportTests.cs

@ -38,10 +38,12 @@ namespace ICSharpCode.ILSpy.Tests.Commands; @@ -38,10 +38,12 @@ namespace ICSharpCode.ILSpy.Tests.Commands;
/// <summary>
/// Every File -> Save Code path for an assembly runs behind a cancellable progress overlay, never a
/// bare Task.Run: the .csproj export goes through <see cref="ProjectExport.ExportSingleAssemblyAsync"/>
/// (the Export Project command's frozen determinate-progress tab), and the single-file save goes through
/// <see cref="SaveCodeHelper.SaveNodeToFileWithProgressAsync"/> (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: <see cref="ProjectExport.ExportSingleAssemblyAsync"/> for a
/// .csproj, <see cref="ProjectExport.ExportSolutionAsync"/> for several assemblies as a .sln. The
/// single-file save goes through <see cref="SaveCodeHelper.SaveNodeToFileWithProgressAsync"/> (the
/// same overlay normal decompilation uses). These assert each path writes its output and surfaces a
/// report/breadcrumb in a tab.
/// </summary>
[TestFixture]
public class SaveCodeProjectExportTests
@ -83,6 +85,53 @@ 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<LanguageService>()
.Languages.OfType<CSharpLanguage>().First();
var settings = AppComposition.Current.GetExport<SettingsService>().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<ContentTabPage>()
.Select(t => t.Content).OfType<DecompilerTabPageModel>()
.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()
{

4
ILSpy/Commands/FileCommands.cs

@ -240,9 +240,9 @@ namespace ICSharpCode.ILSpy.Commands @@ -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;
}

101
ILSpy/Commands/ProjectExport.cs

@ -26,6 +26,7 @@ using System.Threading.Tasks; @@ -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; @@ -40,10 +41,18 @@ using ICSharpCode.ILSpy.Views;
namespace ICSharpCode.ILSpy.Commands
{
/// <summary>
/// The shared launcher behind the dedicated "Export Project/Solution..." entry (File menu +
/// assembly context menu): recognises an exportable selection, shows
/// <see cref="ExportProjectDialog"/>, then runs <see cref="ProjectExporter"/> 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 <see cref="TryGetExportableAssemblies"/> and run
/// <see cref="ProjectExporter"/> on a settings clone behind a frozen progress tab
/// (<see cref="RunExportAsync"/>), differing only in how the target and options are chosen:
/// <list type="bullet">
/// <item>"Export Project/Solution..." (File menu + assembly context menu) asks
/// <see cref="ExportProjectDialog"/> for an output folder and per-run overrides.</item>
/// <item>Save Code on one assembly, with a project extension picked in the file dialog, exports
/// that project with the live settings (<see cref="ExportSingleAssemblyAsync"/>).</item>
/// <item>Save Code on several assemblies exports a solution to a picked <c>.sln</c> path, again
/// with the live settings (<see cref="PromptAndExportSolutionAsync"/>).</item>
/// </list>
/// </summary>
internal static class ProjectExport
{
@ -65,6 +74,14 @@ namespace ICSharpCode.ILSpy.Commands @@ -65,6 +74,14 @@ namespace ICSharpCode.ILSpy.Commands
return true;
}
/// <summary>
/// True when <paramref name="nodes"/> is the selection shape Save Code maps onto a solution:
/// several assembly nodes that all loaded as valid assemblies.
/// </summary>
public static bool TryGetSolutionAssemblies(IReadOnlyList<SharpTreeNode>? nodes,
out List<LoadedAssembly> assemblies)
=> TryGetExportableAssemblies(nodes, out assemblies, out var solutionMode) && solutionMode;
public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> assemblies,
bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService)
{
@ -82,13 +99,8 @@ namespace ICSharpCode.ILSpy.Commands @@ -82,13 +99,8 @@ namespace ICSharpCode.ILSpy.Commands
}
/// <summary>
/// Exports a single assembly as a decompiled project into <paramref name="outputDirectory"/>, 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 <see cref="ProjectExporter"/> +
/// 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 <see cref="RunExportAsync"/>) so the same operation reads the same
/// however it was started.
/// Exports a single assembly as a decompiled project into <paramref name="outputDirectory"/>.
/// This is the File -> Save Code path for a project extension.
/// </summary>
public static Task ExportSingleAssemblyAsync(LoadedAssembly assembly, string outputDirectory,
DecompilerSettings settings, Language language, DockWorkspace dockWorkspace)
@ -97,9 +109,52 @@ namespace ICSharpCode.ILSpy.Commands @@ -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<LoadedAssembly> { assembly }, solutionMode: false,
OptionsFrom(settings, outputDirectory), settings.Clone(), language, dockWorkspace);
}
/// <summary>
/// Prompts for a target <c>.sln</c> file and exports <paramref name="assemblies"/> 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.
/// </summary>
public static async Task PromptAndExportSolutionAsync(IReadOnlyList<LoadedAssembly> 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<SettingsService>()?.CreateEffectiveDecompilerSettings()
?? new DecompilerSettings();
await ExportSolutionAsync(assemblies, path, settings, language, dockWorkspace).ConfigureAwait(true);
}
/// <summary>
/// Exports <paramref name="assemblies"/> as a solution written to <paramref name="solutionPath"/>,
/// one decompiled project each. Public so tests (and scripted callers) can bypass the file picker.
/// </summary>
public static Task ExportSolutionAsync(IReadOnlyList<LoadedAssembly> 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 @@ -108,24 +163,22 @@ namespace ICSharpCode.ILSpy.Commands
UseDebugSymbols: settings.UseDebugSymbols,
StrongNameKeyFile: null,
GeneratePdb: false,
EmbedSourceFilesInPdb: false);
return RunExportAsync(new List<LoadedAssembly> { 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<LoadedAssembly> 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);

15
ILSpy/Commands/ProjectExportOptions.cs

@ -19,10 +19,16 @@ @@ -19,10 +19,16 @@
namespace ICSharpCode.ILSpy.Commands
{
/// <summary>
/// The configuration chosen in the Export Project/Solution dialog and consumed by
/// <see cref="ProjectExporter"/>. 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 <see cref="ProjectExporter"/>: 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).
/// </summary>
/// <param name="SolutionFileName">
/// In solution mode, the name of the <c>.sln</c> to write inside <paramref name="OutputDirectory"/>.
/// <c>null</c> 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.
/// </param>
public sealed record ProjectExportOptions(
string OutputDirectory,
bool UseSdkStyleProjectFormat,
@ -32,5 +38,6 @@ namespace ICSharpCode.ILSpy.Commands @@ -32,5 +38,6 @@ namespace ICSharpCode.ILSpy.Commands
bool UseDebugSymbols,
string? StrongNameKeyFile,
bool GeneratePdb,
bool EmbedSourceFilesInPdb);
bool EmbedSourceFilesInPdb,
string? SolutionFileName = null);
}

8
ILSpy/Commands/ProjectExporter.cs

@ -57,7 +57,8 @@ namespace ICSharpCode.ILSpy.Commands @@ -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 @@ -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";

9
ILSpy/Commands/SaveCodeContextMenuEntry.cs

@ -17,7 +17,6 @@ @@ -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 @@ -35,7 +34,7 @@ namespace ICSharpCode.ILSpy.Commands
/// <c>AssemblyTreeNode</c> drives project / single-file selection, <c>ResourceTreeNode</c>
/// writes raw bytes, every other node falls through to the generic single-file decompile.</item>
/// <item>Several selected assemblies export a Visual Studio solution (one decompiled project
/// each) via <see cref="SolutionWriter"/>.</item>
/// each) via <see cref="ProjectExport.PromptAndExportSolutionAsync"/>.</item>
/// </list>
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources._SaveCode), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 300)]
@ -62,9 +61,9 @@ namespace ICSharpCode.ILSpy.Commands @@ -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 @@ -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 _);
}
}
}

98
ILSpy/Commands/SolutionExport.cs

@ -1,98 +0,0 @@ @@ -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
{
/// <summary>
/// The shared "export several assemblies as a Visual Studio solution" flow behind both the
/// File &rarr; Save Code command and the Save Code context-menu entry: it recognises a
/// solution-eligible selection, prompts for the <c>.sln</c> path, runs
/// <see cref="SolutionWriter"/> behind the tab's cancellable progress UI, and surfaces the
/// status report in the active decompiler tab.
/// </summary>
internal static class SolutionExport
{
/// <summary>
/// True when <paramref name="nodes"/> is several assembly nodes that all loaded as valid
/// assemblies — the only selection shape that maps onto a multi-project solution.
/// </summary>
public static bool TryGetAssemblies(IReadOnlyList<SharpTreeNode>? nodes, out List<LoadedAssembly> assemblies)
{
assemblies = new List<LoadedAssembly>();
if (nodes is not { Count: > 1 })
return false;
if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true }))
return false;
assemblies = nodes.OfType<AssemblyTreeNode>().Select(n => n.LoadedAssembly).ToList();
return true;
}
/// <summary>
/// Prompts for a target <c>.sln</c> file and exports <paramref name="assemblies"/> 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.
/// </summary>
public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> 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<SettingsService>()?.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);
}
}
}
Loading…
Cancel
Save