mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
The quick Save Code path exports a project (one assembly) or a solution (several) straight through a file picker, with no way to tune the output. Add a dedicated, discoverable "Export Project/Solution..." entry (File menu + assembly context menu, alongside Save Code) that opens a configuration dialog: pick the output folder, preview the projects that will be written (with invalid / duplicate-name / PDB-eligible badges), toggle project-format and decompiler options, optionally sign with a strong-name key, and optionally emit a portable PDB per assembly -- defaulting source-embedding off since the project's .cs are on disk. The export runs on a clone of the live settings (never the persisted instance) behind the tab's cancellable progress UI and reports into the active decompiler tab. StrongNameKeyFile now flows through DecompilationOptions into the project writer, which had no reachable setter before. The engine (ProjectExporter) and the preview computation are split out from the window so they are headless-testable. Assisted-by: Claude:claude-opus-4-8:Claude Codepull/3755/head
15 changed files with 1115 additions and 10 deletions
@ -0,0 +1,114 @@ |
|||||||
|
// 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.Commands; |
||||||
|
using ILSpy.TextView; |
||||||
|
using ILSpy.TreeNodes; |
||||||
|
|
||||||
|
using NUnit.Framework; |
||||||
|
|
||||||
|
namespace ICSharpCode.ILSpy.Tests; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visibility/enablement of the dedicated "Export Project/Solution..." entry (context menu + File
|
||||||
|
/// menu): one or more valid assemblies qualify; any other selection does not. The quick Save Code
|
||||||
|
/// entry is unaffected.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture] |
||||||
|
public class ExportProjectEntryTests |
||||||
|
{ |
||||||
|
static IContextMenuEntry Entry() |
||||||
|
=> AppComposition.Current.GetExport<ContextMenuEntryRegistry>().GetEntry(nameof(Resources.ExportProjectSolution)); |
||||||
|
|
||||||
|
static TextViewContext Context(params SharpTreeNode[] nodes) |
||||||
|
=> new() { SelectedTreeNodes = nodes }; |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Single_Assembly_Is_Visible() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); |
||||||
|
Assert.That(assembly, Is.Not.Null); |
||||||
|
|
||||||
|
Entry().IsVisible(Context(assembly!)).Should().BeTrue("a single valid assembly offers project export"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Multiple_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 solution export"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Mixed_Selection_Is_Hidden() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); |
||||||
|
var type = vm.AssemblyTreeModel.FindNode<TypeTreeNode>( |
||||||
|
"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( |
||||||
|
"only assembly selections map onto a project/solution export"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Empty_Selection_Is_Hidden() |
||||||
|
{ |
||||||
|
await TestHarness.BootAsync(1); |
||||||
|
Entry().IsVisible(Context()).Should().BeFalse(); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Main_Menu_Command_Enables_Only_For_Assembly_Selections() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var command = AppComposition.Current.GetExport<MainMenuCommandRegistry>() |
||||||
|
.GetCommand(nameof(Resources.ExportProjectSolution)); |
||||||
|
|
||||||
|
command.CanExecute(null).Should().BeFalse("nothing selected at startup"); |
||||||
|
|
||||||
|
var assembly = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq"); |
||||||
|
Assert.That(assembly, Is.Not.Null); |
||||||
|
vm.AssemblyTreeModel.SelectNode(assembly); |
||||||
|
|
||||||
|
command.CanExecute(null).Should().BeTrue("a selected assembly enables Export Project/Solution"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,85 @@ |
|||||||
|
// 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 ILSpy.Views; |
||||||
|
|
||||||
|
using NUnit.Framework; |
||||||
|
|
||||||
|
namespace ICSharpCode.ILSpy.Tests.Languages; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Export Project/Solution dialog's preview computation (extracted as a pure static so it is
|
||||||
|
/// testable without the window): per-assembly project name, target subdirectory, and the
|
||||||
|
/// invalid / duplicate-name / PDB-eligible badges.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture] |
||||||
|
public class ExportPreviewRowTests |
||||||
|
{ |
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Solution_Mode_Uses_ShortName_Subdirectories_And_Flags_No_Duplicates() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
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().HaveCount(2); |
||||||
|
|
||||||
|
var rows = ExportProjectDialog.BuildPreviewRows(assemblies, solutionMode: true); |
||||||
|
|
||||||
|
rows.Should().HaveCount(2); |
||||||
|
rows.Should().OnlyContain(r => r.IsValidAssembly); |
||||||
|
rows.Should().OnlyContain(r => !r.HasDuplicateShortName); |
||||||
|
rows.Select(r => r.TargetSubdirectory).Should().BeEquivalentTo(assemblies.Select(a => a.ShortName)); |
||||||
|
rows.Select(r => r.ProjectName).Should().BeEquivalentTo(assemblies.Select(a => a.ShortName + ".csproj")); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Project_Mode_Writes_Into_The_Chosen_Folder_Directly() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() |
||||||
|
.First(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName); |
||||||
|
|
||||||
|
var rows = ExportProjectDialog.BuildPreviewRows(new[] { assembly }, solutionMode: false); |
||||||
|
|
||||||
|
rows.Single().TargetSubdirectory.Should().Be(".", |
||||||
|
"project mode writes the single project into the chosen folder, not a subdirectory"); |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Duplicate_Short_Names_Are_Flagged_On_Every_Affected_Row() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() |
||||||
|
.First(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName); |
||||||
|
|
||||||
|
var rows = ExportProjectDialog.BuildPreviewRows(new[] { assembly, assembly }, solutionMode: true); |
||||||
|
|
||||||
|
rows.Should().HaveCount(2); |
||||||
|
rows.Should().OnlyContain(r => r.HasDuplicateShortName); |
||||||
|
rows.Should().OnlyContain(r => r.BadgeText.Contains("duplicate name")); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,203 @@ |
|||||||
|
// 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.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
using Avalonia.Headless.NUnit; |
||||||
|
|
||||||
|
using AwesomeAssertions; |
||||||
|
|
||||||
|
using ICSharpCode.Decompiler; |
||||||
|
using ICSharpCode.ILSpyX; |
||||||
|
|
||||||
|
using ILSpy; |
||||||
|
using ILSpy.AppEnv; |
||||||
|
using ILSpy.Commands; |
||||||
|
using ILSpy.Languages; |
||||||
|
using ILSpy.ViewModels; |
||||||
|
|
||||||
|
using NUnit.Framework; |
||||||
|
|
||||||
|
namespace ICSharpCode.ILSpy.Tests.Languages; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end tests of <see cref="ProjectExporter"/>, the headless engine behind the Export
|
||||||
|
/// Project/Solution dialog: project mode (one assembly), solution mode (several), optional PDB
|
||||||
|
/// emission, strong-name key copy, and the invariant that it never mutates persisted settings.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture] |
||||||
|
public class ProjectExportRunnerTests |
||||||
|
{ |
||||||
|
static CSharpLanguage Language() |
||||||
|
=> AppComposition.Current.GetExport<LanguageService>().Languages.OfType<CSharpLanguage>().First(); |
||||||
|
|
||||||
|
static List<LoadedAssembly> NonCoreLibAssemblies(MainWindowViewModel vm, int count) |
||||||
|
=> vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() |
||||||
|
.Where(a => a.IsLoadedAsValidAssembly && a.ShortName != TreeNavigation.CoreLibName) |
||||||
|
.GroupBy(a => a.ShortName).Select(g => g.First()) |
||||||
|
.Take(count).ToList(); |
||||||
|
|
||||||
|
static ProjectExportOptions Options(string outputDir, bool generatePdb = false, |
||||||
|
bool embedSourceFilesInPdb = false, string? strongNameKeyFile = null) |
||||||
|
=> new(outputDir, |
||||||
|
UseSdkStyleProjectFormat: true, |
||||||
|
UseNestedDirectoriesForNamespaces: false, |
||||||
|
RemoveDeadCode: false, |
||||||
|
RemoveDeadStores: false, |
||||||
|
UseDebugSymbols: false, |
||||||
|
StrongNameKeyFile: strongNameKeyFile, |
||||||
|
GeneratePdb: generatePdb, |
||||||
|
EmbedSourceFilesInPdb: embedSourceFilesInPdb); |
||||||
|
|
||||||
|
// PDB generation is exercised against a controlled assembly in
|
||||||
|
// ICSharpCode.Decompiler.Tests.PdbGenerationTestRunner. It is deliberately NOT run here against
|
||||||
|
// framework assemblies: the writer's DEBUG-only duplicate-sequence-point Debug.Assert (which also
|
||||||
|
// [Ignore]s the Members PDB fixture) aborts the test host in a Debug build. The runner's project/
|
||||||
|
// solution structure and settings handling are what these tests cover.
|
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Project_Mode_Writes_Csproj_And_Cs() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = NonCoreLibAssemblies(vm, 1).Single(); |
||||||
|
await assembly.GetLoadResultAsync(); |
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProj_" + System.Guid.NewGuid().ToString("N")); |
||||||
|
Directory.CreateDirectory(tempDir); |
||||||
|
try |
||||||
|
{ |
||||||
|
var result = await ProjectExporter.ExportAsync( |
||||||
|
new[] { assembly }, solutionMode: false, Options(tempDir), |
||||||
|
new DecompilerSettings(), Language(), CancellationToken.None); |
||||||
|
|
||||||
|
result.Success.Should().BeTrue(result.StatusText); |
||||||
|
Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1); |
||||||
|
Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
TryDelete(tempDir); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Solution_Mode_Writes_Sln_And_Projects() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assemblies = NonCoreLibAssemblies(vm, 2); |
||||||
|
assemblies.Should().HaveCountGreaterThanOrEqualTo(2); |
||||||
|
foreach (var a in assemblies) |
||||||
|
await a.GetLoadResultAsync(); |
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSln_" + System.Guid.NewGuid().ToString("N")); |
||||||
|
Directory.CreateDirectory(tempDir); |
||||||
|
try |
||||||
|
{ |
||||||
|
var result = await ProjectExporter.ExportAsync( |
||||||
|
assemblies, solutionMode: true, Options(tempDir), |
||||||
|
new DecompilerSettings(), Language(), CancellationToken.None); |
||||||
|
|
||||||
|
result.Success.Should().BeTrue(result.StatusText); |
||||||
|
Directory.EnumerateFiles(tempDir, "*.sln").Should().HaveCount(1); |
||||||
|
foreach (var a in assemblies) |
||||||
|
{ |
||||||
|
var projectDir = Path.Combine(tempDir, a.ShortName); |
||||||
|
Directory.EnumerateFiles(projectDir, "*.csproj").Should().HaveCount(1, a.ShortName); |
||||||
|
} |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
TryDelete(tempDir); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task StrongNameKeyFile_Is_Copied_Into_Project() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = NonCoreLibAssemblies(vm, 1).Single(); |
||||||
|
await assembly.GetLoadResultAsync(); |
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjSnk_" + System.Guid.NewGuid().ToString("N")); |
||||||
|
Directory.CreateDirectory(tempDir); |
||||||
|
var keyFile = Path.Combine(Path.GetTempPath(), "ILSpyKey_" + System.Guid.NewGuid().ToString("N") + ".snk"); |
||||||
|
await File.WriteAllBytesAsync(keyFile, new byte[] { 1, 2, 3, 4 }); |
||||||
|
try |
||||||
|
{ |
||||||
|
var result = await ProjectExporter.ExportAsync( |
||||||
|
new[] { assembly }, solutionMode: false, Options(tempDir, strongNameKeyFile: keyFile), |
||||||
|
new DecompilerSettings(), Language(), CancellationToken.None); |
||||||
|
|
||||||
|
result.Success.Should().BeTrue(result.StatusText); |
||||||
|
File.Exists(Path.Combine(tempDir, Path.GetFileName(keyFile))).Should().BeTrue( |
||||||
|
"the strong-name key must be copied next to the exported project"); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
TryDelete(tempDir); |
||||||
|
TryDeleteFile(keyFile); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
[AvaloniaTest] |
||||||
|
public async Task Runner_Does_Not_Mutate_Persisted_Settings() |
||||||
|
{ |
||||||
|
var (_, vm) = await TestHarness.BootAsync(3); |
||||||
|
var assembly = NonCoreLibAssemblies(vm, 1).Single(); |
||||||
|
await assembly.GetLoadResultAsync(); |
||||||
|
|
||||||
|
var settingsService = AppComposition.Current.GetExport<SettingsService>(); |
||||||
|
bool originalSdk = settingsService.DecompilerSettings.UseSdkStyleProjectFormat; |
||||||
|
|
||||||
|
var tempDir = Path.Combine(Path.GetTempPath(), "ILSpyProjNoMutate_" + System.Guid.NewGuid().ToString("N")); |
||||||
|
Directory.CreateDirectory(tempDir); |
||||||
|
try |
||||||
|
{ |
||||||
|
// Flip every settings-backed toggle relative to the persisted value, on a CLONE.
|
||||||
|
var options = Options(tempDir) with { UseSdkStyleProjectFormat = !originalSdk }; |
||||||
|
await ProjectExporter.ExportAsync( |
||||||
|
new[] { assembly }, solutionMode: false, options, |
||||||
|
settingsService.DecompilerSettings.Clone(), Language(), CancellationToken.None); |
||||||
|
|
||||||
|
settingsService.DecompilerSettings.UseSdkStyleProjectFormat.Should().Be(originalSdk, |
||||||
|
"exporting must apply overrides to a clone, never the persisted settings"); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
TryDelete(tempDir); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static void TryDelete(string dir) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ Directory.Delete(dir, recursive: true); } |
||||||
|
catch { /* best-effort */ } |
||||||
|
} |
||||||
|
|
||||||
|
static void TryDeleteFile(string file) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ File.Delete(file); } |
||||||
|
catch { /* best-effort */ } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
// 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.Composition; |
||||||
|
|
||||||
|
using ICSharpCode.ILSpy.Properties; |
||||||
|
|
||||||
|
using ILSpy.Docking; |
||||||
|
using ILSpy.Languages; |
||||||
|
|
||||||
|
namespace ILSpy.Commands |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Right-click one or more assemblies -> "Export Project/Solution...". Opens the configuration
|
||||||
|
/// dialog (project mode for a single assembly, solution mode for several). Sits alongside the
|
||||||
|
/// quick "Save Code" entry, which keeps its no-dialog behaviour.
|
||||||
|
/// </summary>
|
||||||
|
[ExportContextMenuEntry(Header = nameof(Resources.ExportProjectSolution), Category = nameof(Resources.Save), Icon = "Images/Save", Order = 301)] |
||||||
|
[Shared] |
||||||
|
public sealed class ExportProjectContextMenuEntry : IContextMenuEntry |
||||||
|
{ |
||||||
|
readonly LanguageService languageService; |
||||||
|
readonly DockWorkspace dockWorkspace; |
||||||
|
readonly SettingsService settingsService; |
||||||
|
|
||||||
|
[ImportingConstructor] |
||||||
|
public ExportProjectContextMenuEntry(LanguageService languageService, |
||||||
|
DockWorkspace dockWorkspace, SettingsService settingsService) |
||||||
|
{ |
||||||
|
this.languageService = languageService; |
||||||
|
this.dockWorkspace = dockWorkspace; |
||||||
|
this.settingsService = settingsService; |
||||||
|
} |
||||||
|
|
||||||
|
public bool IsVisible(TextViewContext context) |
||||||
|
=> ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out _, out _); |
||||||
|
|
||||||
|
public bool IsEnabled(TextViewContext context) |
||||||
|
=> ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out _, out _); |
||||||
|
|
||||||
|
public void Execute(TextViewContext context) |
||||||
|
{ |
||||||
|
if (!ProjectExport.TryGetExportableAssemblies(context.SelectedTreeNodes, out var assemblies, out var solutionMode)) |
||||||
|
return; |
||||||
|
_ = ProjectExport.PromptAndExportAsync(assemblies, solutionMode, |
||||||
|
languageService.CurrentLanguage, dockWorkspace, settingsService); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
// 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 global::Avalonia.Controls.ApplicationLifetimes; |
||||||
|
|
||||||
|
using ICSharpCode.ILSpyX; |
||||||
|
using ICSharpCode.ILSpyX.TreeView; |
||||||
|
|
||||||
|
using ILSpy.Docking; |
||||||
|
using ILSpy.Languages; |
||||||
|
using ILSpy.TextView; |
||||||
|
using ILSpy.TreeNodes; |
||||||
|
using ILSpy.Views; |
||||||
|
|
||||||
|
namespace 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProjectExport |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="nodes"/> is one or more assembly nodes that all loaded as valid
|
||||||
|
/// assemblies. <paramref name="solutionMode"/> is set when more than one is selected.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetExportableAssemblies(IReadOnlyList<SharpTreeNode>? nodes, |
||||||
|
out List<LoadedAssembly> assemblies, out bool solutionMode) |
||||||
|
{ |
||||||
|
assemblies = new List<LoadedAssembly>(); |
||||||
|
solutionMode = false; |
||||||
|
if (nodes is not { Count: > 0 }) |
||||||
|
return false; |
||||||
|
if (!nodes.All(n => n is AssemblyTreeNode { LoadedAssembly.IsLoadedAsValidAssembly: true })) |
||||||
|
return false; |
||||||
|
assemblies = nodes.OfType<AssemblyTreeNode>().Select(n => n.LoadedAssembly).ToList(); |
||||||
|
solutionMode = assemblies.Count > 1; |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
public static async Task PromptAndExportAsync(IReadOnlyList<LoadedAssembly> assemblies, |
||||||
|
bool solutionMode, Language language, DockWorkspace dockWorkspace, SettingsService settingsService) |
||||||
|
{ |
||||||
|
var owner = (global::Avalonia.Application.Current?.ApplicationLifetime |
||||||
|
as IClassicDesktopStyleApplicationLifetime)?.MainWindow; |
||||||
|
if (owner == null) |
||||||
|
return; |
||||||
|
|
||||||
|
var dialog = new ExportProjectDialog(settingsService, assemblies, solutionMode); |
||||||
|
var options = await dialog.ShowDialog<ProjectExportOptions?>(owner).ConfigureAwait(true); |
||||||
|
if (options is null || string.IsNullOrEmpty(options.OutputDirectory)) |
||||||
|
return; |
||||||
|
|
||||||
|
var settingsClone = settingsService.DecompilerSettings.Clone(); |
||||||
|
try |
||||||
|
{ |
||||||
|
var output = await dockWorkspace.RunWithCancellation(async token => { |
||||||
|
var result = await ProjectExporter.ExportAsync(assemblies, solutionMode, options, settingsClone, language, token) |
||||||
|
.ConfigureAwait(false); |
||||||
|
var o = new AvaloniaEditTextOutput { Title = ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution }; |
||||||
|
o.Write(result.StatusText); |
||||||
|
o.WriteLine(); |
||||||
|
if (result.Success && Directory.Exists(options.OutputDirectory)) |
||||||
|
{ |
||||||
|
o.AddButton(null, ICSharpCode.ILSpy.Properties.Resources.OpenExplorer, |
||||||
|
(_, _) => OpenFolder(options.OutputDirectory)); |
||||||
|
o.WriteLine(); |
||||||
|
} |
||||||
|
return o; |
||||||
|
}, ICSharpCode.ILSpy.Properties.Resources.ExportProjectSolution); |
||||||
|
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.
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
// 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.
|
||||||
|
|
||||||
|
namespace 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProjectExportOptions( |
||||||
|
string OutputDirectory, |
||||||
|
bool UseSdkStyleProjectFormat, |
||||||
|
bool UseNestedDirectoriesForNamespaces, |
||||||
|
bool RemoveDeadCode, |
||||||
|
bool RemoveDeadStores, |
||||||
|
bool UseDebugSymbols, |
||||||
|
string? StrongNameKeyFile, |
||||||
|
bool GeneratePdb, |
||||||
|
bool EmbedSourceFilesInPdb); |
||||||
|
} |
||||||
@ -0,0 +1,167 @@ |
|||||||
|
// 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.IO; |
||||||
|
using System.Text; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Tasks; |
||||||
|
|
||||||
|
using ICSharpCode.Decompiler; |
||||||
|
using ICSharpCode.Decompiler.CSharp; |
||||||
|
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; |
||||||
|
using ICSharpCode.Decompiler.DebugInfo; |
||||||
|
using ICSharpCode.Decompiler.Metadata; |
||||||
|
using ICSharpCode.ILSpyX; |
||||||
|
|
||||||
|
using ILSpy.Languages; |
||||||
|
|
||||||
|
namespace ILSpy.Commands |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// The UI-agnostic engine behind the Export Project/Solution dialog. Unifies single-assembly
|
||||||
|
/// project export and multi-assembly solution export (the latter via <see cref="SolutionWriter"/>),
|
||||||
|
/// applies the dialog's overrides onto a settings clone, and optionally emits a portable PDB per
|
||||||
|
/// assembly. Returns a <see cref="SolutionExportResult"/> the caller surfaces in the text view.
|
||||||
|
/// Kept separate from the dialog so it is headless-testable.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProjectExporter |
||||||
|
{ |
||||||
|
public static async Task<SolutionExportResult> ExportAsync( |
||||||
|
IReadOnlyList<LoadedAssembly> assemblies, bool solutionMode, ProjectExportOptions options, |
||||||
|
DecompilerSettings settingsClone, Language language, CancellationToken ct) |
||||||
|
{ |
||||||
|
ArgumentNullException.ThrowIfNull(assemblies); |
||||||
|
ArgumentNullException.ThrowIfNull(options); |
||||||
|
ArgumentNullException.ThrowIfNull(settingsClone); |
||||||
|
ArgumentNullException.ThrowIfNull(language); |
||||||
|
|
||||||
|
ApplyOverrides(settingsClone, options); |
||||||
|
|
||||||
|
if (solutionMode) |
||||||
|
{ |
||||||
|
var solutionFilePath = Path.Combine(options.OutputDirectory, SolutionFileName(options.OutputDirectory)); |
||||||
|
var solution = await SolutionWriter.CreateSolutionAsync( |
||||||
|
solutionFilePath, language, assemblies, ct, settingsClone, options.StrongNameKeyFile) |
||||||
|
.ConfigureAwait(false); |
||||||
|
|
||||||
|
var report = new StringBuilder(solution.StatusText); |
||||||
|
if (options.GeneratePdb && solution.Success) |
||||||
|
{ |
||||||
|
await Task.Run(() => GeneratePdbs(assemblies, |
||||||
|
a => Path.Combine(options.OutputDirectory, a.ShortName), settingsClone, options, report, ct), ct) |
||||||
|
.ConfigureAwait(false); |
||||||
|
} |
||||||
|
return new SolutionExportResult(solution.Success, report.ToString()); |
||||||
|
} |
||||||
|
|
||||||
|
return await Task.Run(() => ExportProject(assemblies[0], options, settingsClone, language, ct), ct) |
||||||
|
.ConfigureAwait(false); |
||||||
|
} |
||||||
|
|
||||||
|
static SolutionExportResult ExportProject(LoadedAssembly assembly, ProjectExportOptions options, |
||||||
|
DecompilerSettings settingsClone, Language language, CancellationToken ct) |
||||||
|
{ |
||||||
|
var report = new StringBuilder(); |
||||||
|
bool success; |
||||||
|
try |
||||||
|
{ |
||||||
|
var decompileOptions = new DecompilationOptions(settingsClone) { |
||||||
|
FullDecompilation = true, |
||||||
|
EscapeInvalidIdentifiers = true, |
||||||
|
CancellationToken = ct, |
||||||
|
SaveAsProjectDirectory = options.OutputDirectory, |
||||||
|
StrongNameKeyFile = options.StrongNameKeyFile, |
||||||
|
}; |
||||||
|
language.DecompileAssembly(assembly, new PlainTextOutput(new StringWriter()), decompileOptions); |
||||||
|
report.AppendLine("Project written to " + options.OutputDirectory); |
||||||
|
success = true; |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
throw; |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
report.AppendLine($"Failed to decompile the assembly '{assembly.FileName}':{Environment.NewLine}{e.Message}"); |
||||||
|
success = false; |
||||||
|
} |
||||||
|
|
||||||
|
if (options.GeneratePdb && success) |
||||||
|
GeneratePdbs(new[] { assembly }, _ => options.OutputDirectory, settingsClone, options, report, ct); |
||||||
|
|
||||||
|
return new SolutionExportResult(success, report.ToString()); |
||||||
|
} |
||||||
|
|
||||||
|
static void GeneratePdbs(IReadOnlyList<LoadedAssembly> assemblies, |
||||||
|
Func<LoadedAssembly, string> projectDirectory, DecompilerSettings settingsClone, |
||||||
|
ProjectExportOptions options, StringBuilder report, CancellationToken ct) |
||||||
|
{ |
||||||
|
report.AppendLine(); |
||||||
|
foreach (var assembly in assemblies) |
||||||
|
{ |
||||||
|
ct.ThrowIfCancellationRequested(); |
||||||
|
if (assembly.GetMetadataFileOrNull() is not PEFile file |
||||||
|
|| !PortablePdbWriter.HasCodeViewDebugDirectoryEntry(file)) |
||||||
|
{ |
||||||
|
report.AppendLine($"Skipped PDB for '{assembly.ShortName}': no CodeView debug directory entry."); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
var pdbFileName = Path.Combine(projectDirectory(assembly), |
||||||
|
WholeProjectDecompiler.CleanUpFileName(assembly.ShortName, ".pdb")); |
||||||
|
try |
||||||
|
{ |
||||||
|
using var stream = new FileStream(pdbFileName, FileMode.Create, FileAccess.Write); |
||||||
|
var resolver = assembly.GetAssemblyResolver(); |
||||||
|
var decompiler = new CSharpDecompiler(file, resolver, settingsClone) { |
||||||
|
CancellationToken = ct, |
||||||
|
}; |
||||||
|
new PortablePdbWriter { EmbedSourceFiles = options.EmbedSourceFilesInPdb } |
||||||
|
.WritePdb(file, decompiler, settingsClone, stream); |
||||||
|
report.AppendLine("Generated PDB: " + pdbFileName); |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
throw; |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
report.AppendLine($"Failed to generate a PDB for '{assembly.ShortName}': {e.Message}"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static void ApplyOverrides(DecompilerSettings settings, ProjectExportOptions options) |
||||||
|
{ |
||||||
|
settings.UseSdkStyleProjectFormat = options.UseSdkStyleProjectFormat; |
||||||
|
settings.UseNestedDirectoriesForNamespaces = options.UseNestedDirectoriesForNamespaces; |
||||||
|
settings.RemoveDeadCode = options.RemoveDeadCode; |
||||||
|
settings.RemoveDeadStores = options.RemoveDeadStores; |
||||||
|
settings.UseDebugSymbols = options.UseDebugSymbols; |
||||||
|
} |
||||||
|
|
||||||
|
// The .sln is named after the chosen output folder, falling back to "Solution.sln".
|
||||||
|
static string SolutionFileName(string outputDirectory) |
||||||
|
{ |
||||||
|
var name = Path.GetFileName(outputDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); |
||||||
|
return (string.IsNullOrEmpty(name) ? "Solution" : name) + ".sln"; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
<Window xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:views="clr-namespace:ILSpy.Views" |
||||||
|
x:Class="ILSpy.Views.ExportProjectDialog" |
||||||
|
Width="560" Height="580" MinWidth="460" MinHeight="440" |
||||||
|
WindowStartupLocation="CenterOwner" |
||||||
|
Title="Export Project"> |
||||||
|
<DockPanel Margin="12"> |
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" |
||||||
|
Spacing="6" Margin="0,12,0,0"> |
||||||
|
<Button Name="ExportButton" Content="Export" IsDefault="True" MinWidth="80" /> |
||||||
|
<Button Name="CancelButton" Content="Cancel" IsCancel="True" MinWidth="80" /> |
||||||
|
</StackPanel> |
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled"> |
||||||
|
<StackPanel Spacing="6"> |
||||||
|
<TextBlock Text="Output location:" /> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
<TextBox Grid.Column="0" Name="OutputBox" IsReadOnly="True" /> |
||||||
|
<Button Grid.Column="1" Name="BrowseOutputButton" Content="Browse..." Margin="6,0,0,0" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<TextBlock Text="Projects to export:" Margin="0,6,0,0" /> |
||||||
|
<Border BorderBrush="#80808080" BorderThickness="1" Height="150"> |
||||||
|
<ListBox Name="PreviewList"> |
||||||
|
<ListBox.ItemTemplate> |
||||||
|
<DataTemplate x:DataType="views:ProjectPreviewRow"> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Display}" VerticalAlignment="Center" /> |
||||||
|
<TextBlock Grid.Column="1" Text="{Binding BadgeText}" Foreground="Gray" |
||||||
|
VerticalAlignment="Center" Margin="8,0,0,0" /> |
||||||
|
</Grid> |
||||||
|
</DataTemplate> |
||||||
|
</ListBox.ItemTemplate> |
||||||
|
</ListBox> |
||||||
|
</Border> |
||||||
|
<TextBlock Name="ConflictWarning" Foreground="#C0392B" TextWrapping="Wrap" |
||||||
|
IsVisible="False" |
||||||
|
Text="Two or more selected assemblies share a name; a solution cannot be generated. Deselect the duplicates." /> |
||||||
|
|
||||||
|
<TextBlock Text="Options" FontWeight="Bold" Margin="0,8,0,0" /> |
||||||
|
<CheckBox Name="SdkStyleCheck" Content="Use SDK-style project format (*.csproj)" /> |
||||||
|
<CheckBox Name="NestedDirsCheck" Content="Use nested directories for namespaces" /> |
||||||
|
<CheckBox Name="RemoveDeadCodeCheck" Content="Remove dead code" /> |
||||||
|
<CheckBox Name="RemoveDeadStoresCheck" Content="Remove dead stores" /> |
||||||
|
<CheckBox Name="UseDebugSymbolsCheck" Content="Use variable names from debug symbols" /> |
||||||
|
|
||||||
|
<TextBlock Text="Strong-name key file:" Margin="0,8,0,0" /> |
||||||
|
<Grid ColumnDefinitions="*,Auto"> |
||||||
|
<TextBox Grid.Column="0" Name="KeyFileBox" IsReadOnly="True" /> |
||||||
|
<Button Grid.Column="1" Name="BrowseKeyButton" Content="Browse..." Margin="6,0,0,0" /> |
||||||
|
</Grid> |
||||||
|
|
||||||
|
<CheckBox Name="GeneratePdbCheck" Content="Generate portable PDB" Margin="0,8,0,0" /> |
||||||
|
<CheckBox Name="EmbedSourceCheck" Content="Embed source files in PDB" Margin="20,0,0,0" IsEnabled="False" /> |
||||||
|
</StackPanel> |
||||||
|
</ScrollViewer> |
||||||
|
</DockPanel> |
||||||
|
</Window> |
||||||
@ -0,0 +1,202 @@ |
|||||||
|
// 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.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
|
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
using Avalonia.Platform.Storage; |
||||||
|
|
||||||
|
using ICSharpCode.Decompiler.DebugInfo; |
||||||
|
using ICSharpCode.Decompiler.Metadata; |
||||||
|
using ICSharpCode.ILSpyX; |
||||||
|
|
||||||
|
using ILSpy.AppEnv; |
||||||
|
using ILSpy.Commands; |
||||||
|
|
||||||
|
namespace ILSpy.Views |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// One row of the export preview: the project that will be written for an assembly plus the
|
||||||
|
/// badges (invalid / duplicate-name / PDB-eligible) computed by
|
||||||
|
/// <see cref="ExportProjectDialog.BuildPreviewRows"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ProjectPreviewRow( |
||||||
|
string ProjectName, string TargetSubdirectory, |
||||||
|
bool IsValidAssembly, bool HasDuplicateShortName, bool IsPdbEligible) |
||||||
|
{ |
||||||
|
public string Display => TargetSubdirectory is { Length: > 0 } sub && sub != "." |
||||||
|
? sub + " / " + ProjectName |
||||||
|
: ProjectName; |
||||||
|
|
||||||
|
public string BadgeText { |
||||||
|
get { |
||||||
|
var badges = new List<string>(); |
||||||
|
if (!IsValidAssembly) |
||||||
|
badges.Add("not a valid assembly"); |
||||||
|
if (HasDuplicateShortName) |
||||||
|
badges.Add("duplicate name"); |
||||||
|
if (IsPdbEligible) |
||||||
|
badges.Add("PDB"); |
||||||
|
return string.Join(" ", badges); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration dialog for the "Export Project/Solution" command. Adapts to the selection
|
||||||
|
/// (one valid assembly = project mode, several = solution mode), pre-fills format/decompiler
|
||||||
|
/// toggles from the live settings, previews the projects to be written, and returns a
|
||||||
|
/// <see cref="ProjectExportOptions"/> (or <c>null</c> on cancel) via
|
||||||
|
/// <see cref="Window.ShowDialog{TResult}"/>. All export work happens in
|
||||||
|
/// <see cref="ProjectExporter"/>; this window only collects choices.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ExportProjectDialog : Window |
||||||
|
{ |
||||||
|
readonly bool solutionMode; |
||||||
|
bool hasDuplicateConflict; |
||||||
|
|
||||||
|
// Design-time / parameterless ctor for the XAML previewer.
|
||||||
|
public ExportProjectDialog() : this(TryResolveSettings(), System.Array.Empty<LoadedAssembly>(), false) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public ExportProjectDialog(SettingsService? settingsService, |
||||||
|
IReadOnlyList<LoadedAssembly> assemblies, bool solutionMode) |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
this.solutionMode = solutionMode; |
||||||
|
Title = solutionMode ? "Export Solution" : "Export Project"; |
||||||
|
|
||||||
|
var rows = BuildPreviewRows(assemblies, solutionMode); |
||||||
|
hasDuplicateConflict = solutionMode && rows.Any(r => r.HasDuplicateShortName); |
||||||
|
PreviewList.ItemsSource = rows; |
||||||
|
ConflictWarning.IsVisible = hasDuplicateConflict; |
||||||
|
|
||||||
|
var settings = settingsService?.DecompilerSettings; |
||||||
|
if (settings != null) |
||||||
|
{ |
||||||
|
SdkStyleCheck.IsChecked = settings.UseSdkStyleProjectFormat; |
||||||
|
NestedDirsCheck.IsChecked = settings.UseNestedDirectoriesForNamespaces; |
||||||
|
RemoveDeadCodeCheck.IsChecked = settings.RemoveDeadCode; |
||||||
|
RemoveDeadStoresCheck.IsChecked = settings.RemoveDeadStores; |
||||||
|
UseDebugSymbolsCheck.IsChecked = settings.UseDebugSymbols; |
||||||
|
} |
||||||
|
|
||||||
|
BrowseOutputButton.Click += async (_, _) => { |
||||||
|
var folder = await FilePickers.PickFolderAsync("Select the export output folder"); |
||||||
|
if (!string.IsNullOrEmpty(folder)) |
||||||
|
{ |
||||||
|
OutputBox.Text = folder; |
||||||
|
UpdateExportEnabled(); |
||||||
|
} |
||||||
|
}; |
||||||
|
BrowseKeyButton.Click += async (_, _) => await BrowseKeyFileAsync(); |
||||||
|
|
||||||
|
// Embedding source into the PDB only makes sense once a PDB is being generated; default it
|
||||||
|
// off in project mode (the .cs are already written to disk next to it).
|
||||||
|
GeneratePdbCheck.IsChecked = false; |
||||||
|
EmbedSourceCheck.IsChecked = false; |
||||||
|
GeneratePdbCheck.IsCheckedChanged += (_, _) => |
||||||
|
EmbedSourceCheck.IsEnabled = GeneratePdbCheck.IsChecked == true; |
||||||
|
|
||||||
|
ExportButton.Click += (_, _) => Close(BuildOptions()); |
||||||
|
CancelButton.Click += (_, _) => Close(null); |
||||||
|
|
||||||
|
UpdateExportEnabled(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes one preview row per assembly: the project name + target subdirectory (solution
|
||||||
|
/// mode places each project in a <c>ShortName</c> subfolder; project mode writes into the
|
||||||
|
/// chosen folder directly) and the invalid / duplicate-name / PDB-eligible badges. Pure and
|
||||||
|
/// side-effect free so it can be unit-tested without the window.
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyList<ProjectPreviewRow> BuildPreviewRows( |
||||||
|
IReadOnlyList<LoadedAssembly> assemblies, bool solutionMode) |
||||||
|
{ |
||||||
|
var duplicates = assemblies |
||||||
|
.GroupBy(a => a.ShortName) |
||||||
|
.Where(g => g.Count() > 1) |
||||||
|
.Select(g => g.Key) |
||||||
|
.ToHashSet(); |
||||||
|
|
||||||
|
var rows = new List<ProjectPreviewRow>(assemblies.Count); |
||||||
|
foreach (var assembly in assemblies) |
||||||
|
{ |
||||||
|
bool pdbEligible = false; |
||||||
|
try |
||||||
|
{ |
||||||
|
pdbEligible = assembly.GetMetadataFileOrNull() is PEFile file |
||||||
|
&& PortablePdbWriter.HasCodeViewDebugDirectoryEntry(file); |
||||||
|
} |
||||||
|
catch |
||||||
|
{ |
||||||
|
pdbEligible = false; |
||||||
|
} |
||||||
|
|
||||||
|
rows.Add(new ProjectPreviewRow( |
||||||
|
ProjectName: assembly.ShortName + ".csproj", |
||||||
|
TargetSubdirectory: solutionMode ? assembly.ShortName : ".", |
||||||
|
IsValidAssembly: assembly.IsLoadedAsValidAssembly, |
||||||
|
HasDuplicateShortName: duplicates.Contains(assembly.ShortName), |
||||||
|
IsPdbEligible: pdbEligible)); |
||||||
|
} |
||||||
|
return rows; |
||||||
|
} |
||||||
|
|
||||||
|
ProjectExportOptions BuildOptions() |
||||||
|
=> new( |
||||||
|
OutputBox.Text ?? string.Empty, |
||||||
|
SdkStyleCheck.IsChecked == true, |
||||||
|
NestedDirsCheck.IsChecked == true, |
||||||
|
RemoveDeadCodeCheck.IsChecked == true, |
||||||
|
RemoveDeadStoresCheck.IsChecked == true, |
||||||
|
UseDebugSymbolsCheck.IsChecked == true, |
||||||
|
string.IsNullOrEmpty(KeyFileBox.Text) ? null : KeyFileBox.Text, |
||||||
|
GeneratePdbCheck.IsChecked == true, |
||||||
|
EmbedSourceCheck.IsChecked == true); |
||||||
|
|
||||||
|
void UpdateExportEnabled() |
||||||
|
=> ExportButton.IsEnabled = !string.IsNullOrEmpty(OutputBox.Text) && !hasDuplicateConflict; |
||||||
|
|
||||||
|
async System.Threading.Tasks.Task BrowseKeyFileAsync() |
||||||
|
{ |
||||||
|
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { |
||||||
|
Title = "Select strong-name key file", |
||||||
|
AllowMultiple = false, |
||||||
|
FileTypeFilter = new[] { |
||||||
|
new FilePickerFileType("Strong-name key (*.snk)") { Patterns = new[] { "*.snk" } }, |
||||||
|
FilePickerFileTypes.All, |
||||||
|
}, |
||||||
|
}); |
||||||
|
var path = files.Count > 0 ? files[0].TryGetLocalPath() : null; |
||||||
|
if (!string.IsNullOrEmpty(path)) |
||||||
|
KeyFileBox.Text = path; |
||||||
|
} |
||||||
|
|
||||||
|
static SettingsService? TryResolveSettings() |
||||||
|
{ |
||||||
|
try |
||||||
|
{ return AppComposition.Current.GetExport<SettingsService>(); } |
||||||
|
catch |
||||||
|
{ return null; } |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue