From d978ecf88125d337ed13a54079797fbb9f493613 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 15 Jul 2026 21:24:20 +0200 Subject: [PATCH] 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 --- .../Commands/SaveCodeProjectExportTests.cs | 57 +++++++++- ILSpy/Commands/FileCommands.cs | 4 +- ILSpy/Commands/ProjectExport.cs | 101 +++++++++++++----- ILSpy/Commands/ProjectExportOptions.cs | 15 ++- ILSpy/Commands/ProjectExporter.cs | 8 +- ILSpy/Commands/SaveCodeContextMenuEntry.cs | 9 +- ILSpy/Commands/SolutionExport.cs | 98 ----------------- 7 files changed, 152 insertions(+), 140 deletions(-) delete mode 100644 ILSpy/Commands/SolutionExport.cs 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); - } - - } -}