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();