diff --git a/ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs b/ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs
new file mode 100644
index 000000000..782e5fd42
--- /dev/null
+++ b/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
+{
+ ///
+ /// This interface allows plugins to create custom nodes for resources.
+ ///
+ 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);
+ }
+}
diff --git a/ICSharpCode.ILSpyX/Util/GuessFileType.cs b/ICSharpCode.ILSpyX/Util/GuessFileType.cs
new file mode 100644
index 000000000..5965bb373
--- /dev/null
+++ b/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
+{
+ ///
+ /// Static methods for determining the type of a file.
+ ///
+ 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
+ }
+}
diff --git a/ILSpy/Assets/Icons/Save.svg b/ILSpy/Assets/Icons/Save.svg
new file mode 100644
index 000000000..e2a1dec56
--- /dev/null
+++ b/ILSpy/Assets/Icons/Save.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ILSpy/Commands/FilePickers.cs b/ILSpy/Commands/FilePickers.cs
new file mode 100644
index 000000000..1c9a6b559
--- /dev/null
+++ b/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
+{
+ ///
+ /// Helpers around for save dialogs. Mirrors the WPF
+ /// SaveFileDialog Filter API ("Display|*.ext;*.ext|...") so callers can keep the existing
+ /// strings.
+ ///
+ public static class FilePickers
+ {
+ ///
+ /// Shows a save-file picker. uses WPF SaveFileDialog syntax —
+ /// pairs of display|patterns joined by |.
+ /// pre-fills the file name (no path). Returns the selected absolute path, or null
+ /// if the user cancelled.
+ ///
+ public static async Task 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();
+ }
+
+ /// "PNG (*.png)|*.png|All files|*.*" → two file types.
+ static IReadOnlyList ParseFilter(string filter)
+ {
+ var result = new List();
+ 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;
+ }
+ }
+}
diff --git a/ILSpy/Images.cs b/ILSpy/Images.cs
index 0a5dd842c..94ee6ca4b 100644
--- a/ILSpy/Images.cs
+++ b/ILSpy/Images.cs
@@ -73,6 +73,7 @@ namespace ILSpy.Images
public static readonly IImage Back = LoadSvg(nameof(Back));
public static readonly IImage Forward = LoadSvg(nameof(Forward));
public static readonly IImage Open = LoadSvg(nameof(Open));
+ public static readonly IImage Save = LoadSvg(nameof(Save));
// Containers
public static readonly IImage Namespace = LoadSvg(nameof(Namespace));
diff --git a/ILSpy/TreeNodes/ILSpyTreeNode.cs b/ILSpy/TreeNodes/ILSpyTreeNode.cs
index 72a8da4bd..1de6c0ea7 100644
--- a/ILSpy/TreeNodes/ILSpyTreeNode.cs
+++ b/ILSpy/TreeNodes/ILSpyTreeNode.cs
@@ -16,7 +16,11 @@
// 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 ICSharpCode.Decompiler;
+using ICSharpCode.ILSpyX.Abstractions;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy.AppEnv;
@@ -24,8 +28,10 @@ using ILSpy.Languages;
namespace ILSpy.TreeNodes
{
- public abstract class ILSpyTreeNode : SharpTreeNode
+ public abstract class ILSpyTreeNode : SharpTreeNode, ITreeNode
{
+ IEnumerable ITreeNode.Children => Children.OfType();
+
static LanguageService? cachedLanguageService;
///
@@ -35,6 +41,15 @@ namespace ILSpy.TreeNodes
protected static LanguageService LanguageService
=> cachedLanguageService ??= AppComposition.Current.GetExport();
+ ///
+ /// All MEF-discovered instances. Resource-tree nodes
+ /// walk this list to dispatch a to
+ /// the right specialised node (image / xml / .resources / …) before falling back to the
+ /// generic node.
+ ///
+ protected static ICollection ResourceNodeFactories { get; }
+ = AppComposition.Current.GetExports().ToArray();
+
public Language Language => LanguageService.CurrentLanguage;
///
@@ -54,5 +69,13 @@ namespace ILSpy.TreeNodes
{
language.WriteCommentLine(output, Text?.ToString() ?? GetType().Name);
}
+
+ ///
+ /// 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).
+ ///
+ public virtual bool Save() => false;
}
}
diff --git a/ILSpy/TreeNodes/ResourceEntryNode.cs b/ILSpy/TreeNodes/ResourceEntryNode.cs
index 057b81c8d..f38a7527f 100644
--- a/ILSpy/TreeNodes/ResourceEntryNode.cs
+++ b/ILSpy/TreeNodes/ResourceEntryNode.cs
@@ -18,11 +18,17 @@
using System;
using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.IL;
+using ICSharpCode.Decompiler.Metadata;
+using ILSpy.Commands;
using ILSpy.Languages;
+using ILSpy.TextView;
namespace ILSpy.TreeNodes
{
@@ -51,9 +57,48 @@ namespace ILSpy.TreeNodes
{
using var data = OpenStream();
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)
=> new ResourceEntryNode(name, () => new MemoryStream(data));
+
+ ///
+ /// Walks and returns the first node any
+ /// factory builds for . Falls back to a plain
+ /// when no factory claims it. The single dispatch entry
+ /// point used by both and packages.
+ ///
+ public static ILSpyTreeNode Create(Resource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ return ResourceNodeFactories
+ .Select(f => f.CreateNode(resource))
+ .OfType()
+ .FirstOrDefault()
+ ?? new ResourceTreeNode(resource);
+ }
}
}
diff --git a/ILSpy/TreeNodes/ResourceTreeNode.cs b/ILSpy/TreeNodes/ResourceTreeNode.cs
index 64acb49d0..508c908ac 100644
--- a/ILSpy/TreeNodes/ResourceTreeNode.cs
+++ b/ILSpy/TreeNodes/ResourceTreeNode.cs
@@ -17,12 +17,17 @@
// DEALINGS IN THE SOFTWARE.
using System;
+using System.IO;
+using System.Threading.Tasks;
using ICSharpCode.Decompiler;
+using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.IL;
using ICSharpCode.Decompiler.Metadata;
+using ILSpy.Commands;
using ILSpy.Languages;
+using ILSpy.TextView;
namespace ILSpy.TreeNodes
{
@@ -49,14 +54,36 @@ namespace ILSpy.TreeNodes
var sizeInBytes = Resource.TryGetLength();
var sizeInBytesText = sizeInBytes == null ? "" : ", " + sizeInBytes + " bytes";
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 static ILSpyTreeNode Create(Resource resource)
+ public override bool Save()
{
- ArgumentNullException.ThrowIfNull(resource);
- if (resource.Name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase))
- return new ResourcesFileTreeNode(resource);
- return new ResourceTreeNode(resource);
+ _ = SaveAsync();
+ return true;
}
+
+ async Task SaveAsync()
+ {
+ var defaultName = Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(Resource.Name));
+ var path = await FilePickers.SaveAsync("All files|*.*", defaultName).ConfigureAwait(false);
+ if (path == null)
+ 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);
}
}
diff --git a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs
index f0999bdbc..203ae5f89 100644
--- a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs
+++ b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs
@@ -18,17 +18,31 @@
using System;
using System.Collections.Generic;
+using System.Composition;
using System.IO;
using System.Linq;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Util;
+using ICSharpCode.ILSpyX.Abstractions;
using ILSpy.Languages;
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;
+ }
+ }
+
///
/// A .resources file embedded in an assembly. Children are
/// for byte-payload entries; string entries are