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); - } - - } -}