Browse Source

Extract Package Entry context menu — save items to disk

Right-click → "Extract Package Entry" on items inside a NuGet zip or .NET
single-file bundle. Single-item selection (an AssemblyTreeNode or a
ResourceTreeNode) pops a SaveFileDialog; multi-item or PackageFolderTreeNode
selection pops a folder-picker. Runs the extraction on a background thread
through the new DockWorkspace.RunWithCancellation infrastructure, then
ShowText reports per-entry write paths + total elapsed time + an "Open
Explorer" button that selects the result in the OS file manager.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
a5e25977cd
  1. 282
      ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs
  2. 15
      ILSpy/TreeNodes/AssemblyTreeNode.cs

282
ILSpy/Commands/ExtractPackageEntryContextMenuEntry.cs

@ -0,0 +1,282 @@
// 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.Composition;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.Util;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.Docking;
using ILSpy.TextView;
using ILSpy.TreeNodes;
using static ICSharpCode.ILSpyX.LoadedPackage;
namespace ILSpy.Commands
{
/// <summary>
/// Right-click → "Extract Package Entry". Saves a selection of bundle items
/// (assemblies, resource entries, package folders) to disk. Single-item selection
/// pops a file-save dialog; multi-item or folder selection pops a folder-pick.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.ExtractPackageEntry), Category = nameof(Resources.Save), Icon = "Images/Save")]
[Shared]
public sealed class ExtractPackageEntryContextMenuEntry : IContextMenuEntry
{
readonly DockWorkspace dockWorkspace;
[ImportingConstructor]
public ExtractPackageEntryContextMenuEntry(DockWorkspace dockWorkspace)
{
this.dockWorkspace = dockWorkspace;
}
public bool IsEnabled(TextViewContext context) => true;
public bool IsVisible(TextViewContext context)
=> context.SelectedTreeNodes?.Any(IsBundleItem) == true;
public void Execute(TextViewContext context)
{
if (context.SelectedTreeNodes == null)
return;
var selectedNodes = Array.FindAll(context.SelectedTreeNodes, IsBundleItem);
if (selectedNodes.Length == 0)
return;
// Initial directory for the picker — the path of the outer bundle assembly.
var bundleNode = selectedNodes.FirstOrDefault()?.Ancestors().OfType<AssemblyTreeNode>()
.FirstOrDefault(asm => asm.PackageEntry == null);
if (bundleNode == null)
return;
_ = ExecuteAsync(selectedNodes);
}
async Task ExecuteAsync(SharpTreeNode[] selectedNodes)
{
if (selectedNodes is [AssemblyTreeNode { PackageEntry: { } assembly }])
{
var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(assembly.Name));
var path = await FilePickers.SaveAsync(
".NET assemblies (*.dll, *.exe, *.winmd)|*.dll;*.exe;*.winmd|All files|*.*",
defaultName);
if (string.IsNullOrEmpty(path))
return;
await SaveAsync(selectedNodes, path, isFile: true);
}
else if (selectedNodes is [ResourceTreeNode { Resource: { } resource }])
{
var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(resource.Name));
var path = await FilePickers.SaveAsync("All files|*.*", defaultName);
if (string.IsNullOrEmpty(path))
return;
await SaveAsync(selectedNodes, path, isFile: true);
}
else
{
var folder = await FilePickers.PickFolderAsync("Select target folder");
if (string.IsNullOrEmpty(folder))
return;
await SaveAsync(selectedNodes, folder, isFile: false);
}
}
internal Task SaveAsync(IReadOnlyList<SharpTreeNode> nodes, string path, bool isFile)
=> SaveAsync(dockWorkspace, nodes, path, isFile);
internal static async Task SaveAsync(DockWorkspace dockWorkspace, IReadOnlyList<SharpTreeNode> nodes, string path, bool isFile)
{
try
{
var output = await dockWorkspace.RunWithCancellation(token => Task.Run(() => {
var output = new AvaloniaEditTextOutput { Title = "Extract package entry" };
var stopwatch = Stopwatch.StartNew();
var fileNameCounts = new Dictionary<string, int>(Platform.FileNameComparer);
foreach (var (entry, fileName) in CollectAllFiles(nodes))
{
var actualFileName = WholeProjectDecompiler.SanitizeFileName(fileName);
while (fileNameCounts.TryGetValue(actualFileName, out var index))
{
index++;
fileNameCounts[actualFileName] = index;
actualFileName = Path.ChangeExtension(actualFileName, index + Path.GetExtension(actualFileName));
}
if (!fileNameCounts.ContainsKey(actualFileName))
fileNameCounts[actualFileName] = 1;
SaveEntry(output, entry, isFile ? path : Path.Combine(path, actualFileName));
}
stopwatch.Stop();
output.Write(string.Format(Resources.GenerationCompleteInSeconds, stopwatch.Elapsed.TotalSeconds.ToString("F1")));
output.WriteLine();
output.WriteLine();
var openTarget = isFile ? path : path;
output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenInShell(openTarget, isFile));
output.WriteLine();
return output;
}, token), "Extracting…");
dockWorkspace.ShowText(output);
}
catch (OperationCanceledException)
{
// User cancelled — leave the previous tab content visible.
}
}
static IEnumerable<(PackageEntry Entry, string TargetFileName)> CollectAllFiles(IReadOnlyList<SharpTreeNode> nodes)
{
foreach (var node in nodes)
{
if (node is AssemblyTreeNode { PackageEntry: { } assembly })
{
yield return (assembly, Path.GetFileName(assembly.FullName));
}
else if (node is ResourceTreeNode { Resource: PackageEntry { } resource })
{
yield return (resource, Path.GetFileName(resource.FullName));
}
else if (node is AssemblyTreeNode { PackageKind: not null } asm)
{
var package = asm.LoadedAssembly.GetLoadResultAsync().GetAwaiter().GetResult().Package!;
foreach (var entry in package.Entries)
yield return (entry, entry.FullName);
}
else if (node is PackageFolderTreeNode folder)
{
int prefixLength = 0;
var current = folder.Folder;
if (nodes.Count > 1)
current = current.Parent;
while (current != null)
{
prefixLength += current.Name.Length + 1;
current = current.Parent;
}
if (prefixLength > 0)
prefixLength--;
foreach (var item in TreeTraversal.PreOrder(folder.Folder, f => f.Folders).SelectMany(f => f.Entries))
{
yield return (item, item.FullName.Substring(prefixLength));
}
}
}
}
static void SaveEntry(ITextOutput output, PackageEntry entry, string targetFileName)
{
output.Write(entry.Name + ": ");
using var stream = entry.TryOpenStream();
if (stream == null)
{
output.WriteLine("Could not open stream!");
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(targetFileName)!);
stream.Position = 0;
using var fileStream = new FileStream(targetFileName, FileMode.OpenOrCreate);
stream.CopyTo(fileStream);
output.Write("Written to ");
output.Write(targetFileName);
output.WriteLine();
}
static void OpenInShell(string path, bool selectItem)
{
try
{
if (OperatingSystem.IsWindows())
{
var args = selectItem ? $"/select,\"{path}\"" : $"\"{path}\"";
Process.Start(new ProcessStartInfo("explorer.exe", args) { UseShellExecute = false });
}
else if (OperatingSystem.IsMacOS())
{
var args = selectItem ? $"-R \"{path}\"" : $"\"{path}\"";
Process.Start(new ProcessStartInfo("open", args) { UseShellExecute = false });
}
else
{
var target = selectItem ? Path.GetDirectoryName(path)! : path;
Process.Start(new ProcessStartInfo("xdg-open", target) { UseShellExecute = false });
}
}
catch
{
// Best-effort: user can navigate manually if the shell call fails.
}
}
static bool IsBundleItem(SharpTreeNode node)
{
if (node is AssemblyTreeNode { PackageEntry: { } } or PackageFolderTreeNode)
return true;
if (node is ResourceTreeNode { Resource: PackageEntry { } resource } && resource.PackageQualifiedFileName.StartsWith("bundle://"))
return true;
return false;
}
}
/// <summary>
/// Right-click an unpacked bundle assembly → "Extract All Package Entries". Saves
/// every entry inside the bundle into the picked folder. Shortcut for the multi-
/// select case in <see cref="ExtractPackageEntryContextMenuEntry"/>.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.ExtractAllPackageEntries), Category = nameof(Resources.Save), Icon = "Images/Save")]
[Shared]
public sealed class ExtractAllPackageEntriesContextMenuEntry : IContextMenuEntry
{
readonly DockWorkspace dockWorkspace;
[ImportingConstructor]
public ExtractAllPackageEntriesContextMenuEntry(DockWorkspace dockWorkspace)
{
this.dockWorkspace = dockWorkspace;
}
public bool IsEnabled(TextViewContext context) => true;
public bool IsVisible(TextViewContext context)
{
return context.SelectedTreeNodes is [AssemblyTreeNode { PackageEntry: null, PackageKind: PackageKind.Bundle }];
}
public void Execute(TextViewContext context)
{
if (context.SelectedTreeNodes is not [AssemblyTreeNode { PackageEntry: null } asm])
return;
_ = ExecuteAsync(asm);
}
async Task ExecuteAsync(AssemblyTreeNode asm)
{
var folder = await FilePickers.PickFolderAsync("Select target folder");
if (string.IsNullOrEmpty(folder))
return;
// Reuses the shared Save pipeline from ExtractPackageEntryContextMenuEntry.
await ExtractPackageEntryContextMenuEntry.SaveAsync(dockWorkspace, new[] { (SharpTreeNode)asm }, folder, isFile: false);
}
}
}

15
ILSpy/TreeNodes/AssemblyTreeNode.cs

@ -53,6 +53,21 @@ namespace ILSpy.TreeNodes
/// </summary> /// </summary>
public PackageEntry? PackageEntry { get; } public PackageEntry? PackageEntry { get; }
/// <summary>
/// If this assembly is itself a bundle/package (nuget .zip or .NET single-file
/// bundle), returns the package kind. Returns <see langword="null"/> for plain
/// stand-alone assemblies and for any assembly that hasn't finished loading yet
/// or failed to load.
/// </summary>
public LoadedPackage.PackageKind? PackageKind {
get {
if (LoadedAssembly.HasLoadError || !LoadedAssembly.IsLoaded)
return null;
var loadResult = LoadedAssembly.GetLoadResultAsync().GetAwaiter().GetResult();
return loadResult.Package?.Kind;
}
}
public AssemblyTreeNode(LoadedAssembly assembly) : this(assembly, null) public AssemblyTreeNode(LoadedAssembly assembly) : this(assembly, null)
{ {
} }

Loading…
Cancel
Save