Browse Source

Factory-walking resource dispatcher + Save scaffolding

Move IResourceNodeFactory to ILSpyX.Abstractions and GuessFileType to ILSpyX.Util
so both UIs can consume them. Wire ResourceEntryNode.Create(Resource) to walk the
MEF-discovered IResourceNodeFactory list (falls back to the generic node), make
Avalonia's ILSpyTreeNode implement ITreeNode for that contract, and register a
ResourcesFileTreeNodeFactory to keep .resources files routed correctly. Adds the
Save virtual on ILSpyTreeNode plus inline "Save" buttons + SaveAsync on
ResourceEntryNode/ResourceTreeNode using a new FilePickers helper around
StorageProvider.SaveFilePickerAsync.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
6f955e0a1f
  1. 32
      ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs
  2. 184
      ICSharpCode.ILSpyX/Util/GuessFileType.cs
  3. 1
      ILSpy/Assets/Icons/Save.svg
  4. 93
      ILSpy/Commands/FilePickers.cs
  5. 1
      ILSpy/Images.cs
  6. 25
      ILSpy/TreeNodes/ILSpyTreeNode.cs
  7. 45
      ILSpy/TreeNodes/ResourceEntryNode.cs
  8. 37
      ILSpy/TreeNodes/ResourceTreeNode.cs
  9. 14
      ILSpy/TreeNodes/ResourcesFileTreeNode.cs

32
ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs

@ -0,0 +1,32 @@
// Copyright (c) 2011 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 ICSharpCode.Decompiler.Metadata;
namespace ICSharpCode.ILSpyX.Abstractions
{
/// <summary>
/// This interface allows plugins to create custom nodes for resources.
/// </summary>
public interface IResourceNodeFactory
{
// Null means "this factory doesn't claim the resource"; callers iterate factories until
// one returns non-null and otherwise fall back to a generic node.
ITreeNode? CreateNode(Resource resource);
}
}

184
ICSharpCode.ILSpyX/Util/GuessFileType.cs

@ -0,0 +1,184 @@
// Copyright (c) 2011 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.IO;
using System.Text;
using System.Xml;
namespace ICSharpCode.ILSpyX.Util
{
/// <summary>
/// Static methods for determining the type of a file.
/// </summary>
public static class GuessFileType
{
public static FileType DetectFileType(Stream stream)
{
StreamReader reader;
if (stream.Length >= 2)
{
int firstByte = stream.ReadByte();
int secondByte = stream.ReadByte();
switch ((firstByte << 8) | secondByte)
{
case 0xfffe: // UTF-16 LE BOM / UTF-32 LE BOM
case 0xfeff: // UTF-16 BE BOM
stream.Position -= 2;
reader = new StreamReader(stream, detectEncodingFromByteOrderMarks: true);
break;
case 0xefbb: // start of UTF-8 BOM
if (stream.ReadByte() == 0xbf)
{
reader = new StreamReader(stream, Encoding.UTF8);
break;
}
else
{
return FileType.Binary;
}
default:
if (IsUTF8(stream, (byte)firstByte, (byte)secondByte))
{
stream.Position = 0;
reader = new StreamReader(stream, Encoding.UTF8);
break;
}
else
{
return FileType.Binary;
}
}
}
else
{
return FileType.Binary;
}
// Now we got a StreamReader with the correct encoding
// Check for XML now
try
{
XmlTextReader xmlReader = new XmlTextReader(reader);
xmlReader.XmlResolver = null;
xmlReader.MoveToContent();
return FileType.Xml;
}
catch (XmlException)
{
return FileType.Text;
}
}
static bool IsUTF8(Stream fs, byte firstByte, byte secondByte)
{
int max = (int)Math.Min(fs.Length, 500000); // look at max. 500 KB
const int ASCII = 0;
const int Error = 1;
const int UTF8 = 2;
const int UTF8Sequence = 3;
int state = ASCII;
int sequenceLength = 0;
byte b;
for (int i = 0; i < max; i++)
{
if (i == 0)
{
b = firstByte;
}
else if (i == 1)
{
b = secondByte;
}
else
{
b = (byte)fs.ReadByte();
}
if (b < 0x80)
{
// normal ASCII character
if (state == UTF8Sequence)
{
state = Error;
break;
}
}
else if (b < 0xc0)
{
// 10xxxxxx : continues UTF8 byte sequence
if (state == UTF8Sequence)
{
--sequenceLength;
if (sequenceLength < 0)
{
state = Error;
break;
}
else if (sequenceLength == 0)
{
state = UTF8;
}
}
else
{
state = Error;
break;
}
}
else if (b >= 0xc2 && b < 0xf5)
{
// beginning of byte sequence
if (state == UTF8 || state == ASCII)
{
state = UTF8Sequence;
if (b < 0xe0)
{
sequenceLength = 1; // one more byte following
}
else if (b < 0xf0)
{
sequenceLength = 2; // two more bytes following
}
else
{
sequenceLength = 3; // three more bytes following
}
}
else
{
state = Error;
break;
}
}
else
{
// 0xc0, 0xc1, 0xf5 to 0xff are invalid in UTF-8 (see RFC 3629)
state = Error;
break;
}
}
return state != Error;
}
}
public enum FileType
{
Binary,
Text,
Xml
}
}

1
ILSpy/Assets/Icons/Save.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.icon-canvas-transparent{opacity:0;fill:#f6f6f6}.icon-vs-out{fill:#f6f6f6}.icon-vs-fg{fill:#f0eff1}.icon-vs-action-blue{fill:#00539c}</style><path class="icon-canvas-transparent" d="M16 0v16H0V0h16z" id="canvas"/><path class="icon-vs-out" d="M16 2v14H2.586L0 13.414V2C0 .897.897 0 2 0h12c1.102 0 2 .897 2 2z" id="outline"/><path class="icon-vs-fg" d="M13 3v4H3V3h10zm-9 7v5h2v-3h2v3h4v-5H4z" id="iconFg"/><g id="iconBg"><path class="icon-vs-action-blue" d="M6 12h2v3H6v-3zm9-10v13h-3v-5H4v5H3l-2-2V2a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1zm-2 1H3v4h10V3z"/></g></svg>

After

Width:  |  Height:  |  Size: 626 B

93
ILSpy/Commands/FilePickers.cs

@ -0,0 +1,93 @@
// 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.Threading.Tasks;
using global::Avalonia.Controls.ApplicationLifetimes;
using global::Avalonia.Platform.Storage;
namespace ILSpy.Commands
{
/// <summary>
/// Helpers around <see cref="IStorageProvider"/> for save dialogs. Mirrors the WPF
/// SaveFileDialog Filter API ("Display|*.ext;*.ext|...") so callers can keep the existing
/// strings.
/// </summary>
public static class FilePickers
{
/// <summary>
/// Shows a save-file picker. <paramref name="filter"/> uses WPF SaveFileDialog syntax —
/// pairs of <c>display|patterns</c> joined by <c>|</c>. <paramref name="defaultFileName"/>
/// pre-fills the file name (no path). Returns the selected absolute path, or <c>null</c>
/// if the user cancelled.
/// </summary>
public static async Task<string?> SaveAsync(
string filter,
string? defaultFileName = null,
string? title = null)
{
var owner = (global::Avalonia.Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (owner == null)
return null;
var fileTypes = ParseFilter(filter);
var suggested = defaultFileName != null
? Path.GetFileName(defaultFileName)
: null;
var file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions {
Title = title,
FileTypeChoices = fileTypes,
SuggestedFileName = suggested,
DefaultExtension = fileTypes.Count > 0 ? GuessExtension(fileTypes[0]) : null,
});
return file?.TryGetLocalPath();
}
/// <summary>"PNG (*.png)|*.png|All files|*.*" → two file types.</summary>
static IReadOnlyList<FilePickerFileType> ParseFilter(string filter)
{
var result = new List<FilePickerFileType>();
var parts = filter.Split('|');
for (int i = 0; i + 1 < parts.Length; i += 2)
{
var display = parts[i];
var patterns = parts[i + 1].Split(';');
result.Add(new FilePickerFileType(display) { Patterns = patterns });
}
return result;
}
static string? GuessExtension(FilePickerFileType type)
{
if (type.Patterns is null)
return null;
foreach (var p in type.Patterns)
{
// "*.ext" → "ext"; bare "*" or "*.*" yields no useful extension.
if (p.StartsWith("*.", System.StringComparison.Ordinal) && p.Length > 2 && p[2] != '*')
return p[2..];
}
return null;
}
}
}

1
ILSpy/Images.cs

@ -73,6 +73,7 @@ namespace ILSpy.Images
public static readonly IImage Back = LoadSvg(nameof(Back)); public static readonly IImage Back = LoadSvg(nameof(Back));
public static readonly IImage Forward = LoadSvg(nameof(Forward)); public static readonly IImage Forward = LoadSvg(nameof(Forward));
public static readonly IImage Open = LoadSvg(nameof(Open)); public static readonly IImage Open = LoadSvg(nameof(Open));
public static readonly IImage Save = LoadSvg(nameof(Save));
// Containers // Containers
public static readonly IImage Namespace = LoadSvg(nameof(Namespace)); public static readonly IImage Namespace = LoadSvg(nameof(Namespace));

25
ILSpy/TreeNodes/ILSpyTreeNode.cs

@ -16,7 +16,11 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System.Collections.Generic;
using System.Linq;
using ICSharpCode.Decompiler; using ICSharpCode.Decompiler;
using ICSharpCode.ILSpyX.Abstractions;
using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpyX.TreeView;
using ILSpy.AppEnv; using ILSpy.AppEnv;
@ -24,8 +28,10 @@ using ILSpy.Languages;
namespace ILSpy.TreeNodes namespace ILSpy.TreeNodes
{ {
public abstract class ILSpyTreeNode : SharpTreeNode public abstract class ILSpyTreeNode : SharpTreeNode, ITreeNode
{ {
IEnumerable<ITreeNode> ITreeNode.Children => Children.OfType<ILSpyTreeNode>();
static LanguageService? cachedLanguageService; static LanguageService? cachedLanguageService;
/// <summary> /// <summary>
@ -35,6 +41,15 @@ namespace ILSpy.TreeNodes
protected static LanguageService LanguageService protected static LanguageService LanguageService
=> cachedLanguageService ??= AppComposition.Current.GetExport<LanguageService>(); => cachedLanguageService ??= AppComposition.Current.GetExport<LanguageService>();
/// <summary>
/// All MEF-discovered <see cref="IResourceNodeFactory"/> instances. Resource-tree nodes
/// walk this list to dispatch a <see cref="ICSharpCode.Decompiler.Metadata.Resource"/> to
/// the right specialised node (image / xml / .resources / …) before falling back to the
/// generic node.
/// </summary>
protected static ICollection<IResourceNodeFactory> ResourceNodeFactories { get; }
= AppComposition.Current.GetExports<IResourceNodeFactory>().ToArray();
public Language Language => LanguageService.CurrentLanguage; public Language Language => LanguageService.CurrentLanguage;
/// <summary> /// <summary>
@ -54,5 +69,13 @@ namespace ILSpy.TreeNodes
{ {
language.WriteCommentLine(output, Text?.ToString() ?? GetType().Name); language.WriteCommentLine(output, Text?.ToString() ?? GetType().Name);
} }
/// <summary>
/// Special "Save" handling for nodes that aren't textual code. Default returns false,
/// meaning the host should use the regular decompile-to-file path. Override and return
/// true after writing the node's content to handle Save inline (e.g. raw byte copy for
/// embedded resources).
/// </summary>
public virtual bool Save() => false;
} }
} }

45
ILSpy/TreeNodes/ResourceEntryNode.cs

@ -18,11 +18,17 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ICSharpCode.Decompiler; using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.IL; using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.Metadata;
using ILSpy.Commands;
using ILSpy.Languages; using ILSpy.Languages;
using ILSpy.TextView;
namespace ILSpy.TreeNodes namespace ILSpy.TreeNodes
{ {
@ -51,9 +57,48 @@ namespace ILSpy.TreeNodes
{ {
using var data = OpenStream(); using var data = OpenStream();
language.WriteCommentLine(output, $"{key} = {data.Length} bytes"); language.WriteCommentLine(output, $"{key} = {data.Length} bytes");
if (output is ISmartTextOutput smart)
{
smart.WriteLine();
smart.AddButton(Images.Images.Save, "Save", async (_, _) => await SaveAsync().ConfigureAwait(false));
smart.WriteLine();
}
}
public override bool Save()
{
_ = SaveAsync();
return true;
}
async Task SaveAsync()
{
var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(key));
var path = await FilePickers.SaveAsync("All files|*.*", defaultName).ConfigureAwait(false);
if (path == null)
return;
using var src = OpenStream();
using var dst = File.Create(path);
src.CopyTo(dst);
} }
public static ILSpyTreeNode Create(string name, byte[] data) public static ILSpyTreeNode Create(string name, byte[] data)
=> new ResourceEntryNode(name, () => new MemoryStream(data)); => new ResourceEntryNode(name, () => new MemoryStream(data));
/// <summary>
/// Walks <see cref="ILSpyTreeNode.ResourceNodeFactories"/> and returns the first node any
/// factory builds for <paramref name="resource"/>. Falls back to a plain
/// <see cref="ResourceTreeNode"/> when no factory claims it. The single dispatch entry
/// point used by both <see cref="ResourceListTreeNode"/> and packages.
/// </summary>
public static ILSpyTreeNode Create(Resource resource)
{
ArgumentNullException.ThrowIfNull(resource);
return ResourceNodeFactories
.Select(f => f.CreateNode(resource))
.OfType<ILSpyTreeNode>()
.FirstOrDefault()
?? new ResourceTreeNode(resource);
}
} }
} }

37
ILSpy/TreeNodes/ResourceTreeNode.cs

@ -17,12 +17,17 @@
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System; using System;
using System.IO;
using System.Threading.Tasks;
using ICSharpCode.Decompiler; using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.IL; using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
using ILSpy.Commands;
using ILSpy.Languages; using ILSpy.Languages;
using ILSpy.TextView;
namespace ILSpy.TreeNodes namespace ILSpy.TreeNodes
{ {
@ -49,14 +54,36 @@ namespace ILSpy.TreeNodes
var sizeInBytes = Resource.TryGetLength(); var sizeInBytes = Resource.TryGetLength();
var sizeInBytesText = sizeInBytes == null ? "" : ", " + sizeInBytes + " bytes"; var sizeInBytesText = sizeInBytes == null ? "" : ", " + sizeInBytes + " bytes";
language.WriteCommentLine(output, $"{Resource.Name} ({Resource.ResourceType}, {Resource.Attributes}{sizeInBytesText})"); language.WriteCommentLine(output, $"{Resource.Name} ({Resource.ResourceType}, {Resource.Attributes}{sizeInBytesText})");
if (output is ISmartTextOutput smart)
{
smart.WriteLine();
smart.AddButton(Images.Images.Save, "Save", async (_, _) => await SaveAsync().ConfigureAwait(false));
smart.WriteLine();
}
}
public override bool Save()
{
_ = SaveAsync();
return true;
} }
public static ILSpyTreeNode Create(Resource resource) async Task SaveAsync()
{ {
ArgumentNullException.ThrowIfNull(resource); var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(Resource.Name));
if (resource.Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)) var path = await FilePickers.SaveAsync("All files|*.*", defaultName).ConfigureAwait(false);
return new ResourcesFileTreeNode(resource); if (path == null)
return new ResourceTreeNode(resource); return;
using var src = Resource.TryOpenStream();
if (src == null)
return;
src.Position = 0;
using var dst = File.Create(path);
src.CopyTo(dst);
} }
// Keep the symmetric name from WPF: callers can use ResourceTreeNode.Create or
// ResourceEntryNode.Create interchangeably.
public static ILSpyTreeNode Create(Resource resource) => ResourceEntryNode.Create(resource);
} }
} }

14
ILSpy/TreeNodes/ResourcesFileTreeNode.cs

@ -18,17 +18,31 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Composition;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using ICSharpCode.Decompiler; using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Util; using ICSharpCode.Decompiler.Util;
using ICSharpCode.ILSpyX.Abstractions;
using ILSpy.Languages; using ILSpy.Languages;
namespace ILSpy.TreeNodes namespace ILSpy.TreeNodes
{ {
[Export(typeof(IResourceNodeFactory))]
[Shared]
sealed class ResourcesFileTreeNodeFactory : IResourceNodeFactory
{
public ITreeNode? CreateNode(Resource resource)
{
if (resource.Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase))
return new ResourcesFileTreeNode(resource);
return null;
}
}
/// <summary> /// <summary>
/// A <c>.resources</c> file embedded in an assembly. Children are /// A <c>.resources</c> file embedded in an assembly. Children are
/// <see cref="ResourceEntryNode"/> for byte-payload entries; string entries are /// <see cref="ResourceEntryNode"/> for byte-payload entries; string entries are

Loading…
Cancel
Save