From 6f955e0a1fc5e2fdcd4d2873ed89922af703f2f4 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 2 May 2026 17:24:44 +0200 Subject: [PATCH] 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 --- .../Abstractions/IResourceNodeFactory.cs | 32 +++ ICSharpCode.ILSpyX/Util/GuessFileType.cs | 184 ++++++++++++++++++ ILSpy/Assets/Icons/Save.svg | 1 + ILSpy/Commands/FilePickers.cs | 93 +++++++++ ILSpy/Images.cs | 1 + ILSpy/TreeNodes/ILSpyTreeNode.cs | 25 ++- ILSpy/TreeNodes/ResourceEntryNode.cs | 45 +++++ ILSpy/TreeNodes/ResourceTreeNode.cs | 37 +++- ILSpy/TreeNodes/ResourcesFileTreeNode.cs | 14 ++ 9 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 ICSharpCode.ILSpyX/Abstractions/IResourceNodeFactory.cs create mode 100644 ICSharpCode.ILSpyX/Util/GuessFileType.cs create mode 100644 ILSpy/Assets/Icons/Save.svg create mode 100644 ILSpy/Commands/FilePickers.cs 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