From b9fbb3f586c6e791d2b3fac311f125dd3ff1613c Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 2 May 2026 23:42:57 +0200 Subject: [PATCH] .resources Save offers raw + ResX export formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override Save on ResourcesFileTreeNode to present a dual-format file picker ("Resources file" / "Resource XML") and dispatch by chosen extension; .resx re-emits the entries via the bundled ICSharpCode.Decompiler.Util.ResXResourceWriter while .resources copies the original byte stream. The base ResourceTreeNode's inline Save button now dispatches through the virtual Save() so this override is actually reached — previously it called the private SaveAsync directly, making subclass overrides unreachable from the inline button. Tests pin the WriteResX output and the FilePickers filter parsing. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/Resources/ResourceFactoryTests.cs | 35 ++++++++++++ ILSpy/Commands/FilePickers.cs | 2 +- ILSpy/TreeNodes/ResourceTreeNode.cs | 4 +- ILSpy/TreeNodes/ResourcesFileTreeNode.cs | 53 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/ILSpy.Tests/Resources/ResourceFactoryTests.cs b/ILSpy.Tests/Resources/ResourceFactoryTests.cs index faf820109..e33938e7c 100644 --- a/ILSpy.Tests/Resources/ResourceFactoryTests.cs +++ b/ILSpy.Tests/Resources/ResourceFactoryTests.cs @@ -110,6 +110,41 @@ public class ResourceFactoryTests realised.Should().ContainItemsAssignableTo(); } + [AvaloniaTest] + public void Resources_File_WriteResX_Round_Trips_String_And_Object_Entries() + { + EnsureComposition(); + var node = (ResourcesFileTreeNode)ResourceEntryNode.Create( + new ByteArrayResource("strings.resources", BuildResources( + new (string, object)[] { + ("greeting", "hello"), + ("answer", 42), + }))); + node.EnsureLazyChildren(); + + var ms = new MemoryStream(); + node.WriteResX(ms); + var resx = Encoding.UTF8.GetString(ms.ToArray()); + + // ResX is XML; spot-check both entries land with name + value markup. + resx.Should().Contain("hello"); + resx.Should().Contain("42"); + } + + [Test] + public void FilePickers_ParseFilter_Splits_Pipe_Separated_Display_And_Patterns() + { + var types = global::ILSpy.Commands.FilePickers.ParseFilter( + "Resources file (*.resources)|*.resources|Resource XML (*.resx)|*.resx"); + types.Should().HaveCount(2); + types[0].Name.Should().Be("Resources file (*.resources)"); + types[0].Patterns.Should().BeEquivalentTo(new[] { "*.resources" }); + types[1].Name.Should().Be("Resource XML (*.resx)"); + types[1].Patterns.Should().BeEquivalentTo(new[] { "*.resx" }); + } + static byte[] BuildResources((string Key, object Value)[] entries) { var ms = new MemoryStream(); diff --git a/ILSpy/Commands/FilePickers.cs b/ILSpy/Commands/FilePickers.cs index 1c9a6b559..f30084fe2 100644 --- a/ILSpy/Commands/FilePickers.cs +++ b/ILSpy/Commands/FilePickers.cs @@ -64,7 +64,7 @@ namespace ILSpy.Commands } /// "PNG (*.png)|*.png|All files|*.*" → two file types. - static IReadOnlyList ParseFilter(string filter) + internal static IReadOnlyList ParseFilter(string filter) { var result = new List(); var parts = filter.Split('|'); diff --git a/ILSpy/TreeNodes/ResourceTreeNode.cs b/ILSpy/TreeNodes/ResourceTreeNode.cs index 508c908ac..530afc43a 100644 --- a/ILSpy/TreeNodes/ResourceTreeNode.cs +++ b/ILSpy/TreeNodes/ResourceTreeNode.cs @@ -57,7 +57,9 @@ namespace ILSpy.TreeNodes if (output is ISmartTextOutput smart) { smart.WriteLine(); - smart.AddButton(Images.Images.Save, "Save", async (_, _) => await SaveAsync().ConfigureAwait(false)); + // Dispatch through the virtual Save() so subclasses (ResourcesFileTreeNode) + // can present their own format-specific dialog instead of the generic one. + smart.AddButton(Images.Images.Save, "Save", (_, _) => Save()); smart.WriteLine(); } } diff --git a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs index d6cfbd8f1..011499c1d 100644 --- a/ILSpy/TreeNodes/ResourcesFileTreeNode.cs +++ b/ILSpy/TreeNodes/ResourcesFileTreeNode.cs @@ -23,10 +23,12 @@ using System.IO; using System.Linq; using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp.ProjectDecompiler; using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Util; using ICSharpCode.ILSpyX.Abstractions; +using ILSpy.Commands; using ILSpy.Controls; using ILSpy.Languages; using ILSpy.TextView; @@ -109,6 +111,57 @@ namespace ILSpy.TreeNodes } } + public override bool Save() + { + _ = SaveDialogAsync(); + return true; + } + + async System.Threading.Tasks.Task SaveDialogAsync() + { + var defaultName = System.IO.Path.GetFileName(WholeProjectDecompiler.SanitizeFileName(Resource.Name)); + var path = await FilePickers.SaveAsync( + "Resources file (*.resources)|*.resources|Resource XML (*.resx)|*.resx", + defaultName).ConfigureAwait(false); + if (path == null) + return; + if (path.EndsWith(".resx", System.StringComparison.OrdinalIgnoreCase)) + { + using var dst = File.Create(path); + WriteResX(dst); + } + else + { + using var src = Resource.TryOpenStream(); + if (src == null) + return; + src.Position = 0; + using var dst = File.Create(path); + src.CopyTo(dst); + } + } + + /// + /// Re-emits the underlying .resources stream as a ResX (XML) document into + /// . Skips entries that fail to deserialize so the export still + /// produces a usable file when one entry is malformed. + /// + public void WriteResX(Stream target) + { + using var src = Resource.TryOpenStream(); + if (src == null) + return; + src.Position = 0; + using var writer = new ICSharpCode.Decompiler.Util.ResXResourceWriter(target); + try + { + foreach (var entry in new ResourcesFile(src)) + writer.AddResource(entry.Key, entry.Value); + } + catch (BadImageFormatException) { /* malformed — what we got is what we got */ } + catch (EndOfStreamException) { /* truncated — same */ } + } + public override void Decompile(Language language, ITextOutput output, DecompilationOptions options) { EnsureLazyChildren();