Browse Source

.resources Save offers raw + ResX export formats

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
b9fbb3f586
  1. 35
      ILSpy.Tests/Resources/ResourceFactoryTests.cs
  2. 2
      ILSpy/Commands/FilePickers.cs
  3. 4
      ILSpy/TreeNodes/ResourceTreeNode.cs
  4. 53
      ILSpy/TreeNodes/ResourcesFileTreeNode.cs

35
ILSpy.Tests/Resources/ResourceFactoryTests.cs

@ -110,6 +110,41 @@ public class ResourceFactoryTests
realised.Should().ContainItemsAssignableTo<Control>(); realised.Should().ContainItemsAssignableTo<Control>();
} }
[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("<data name=\"greeting\"");
resx.Should().Contain("<value>hello</value>");
resx.Should().Contain("<data name=\"answer\"");
resx.Should().Contain("<value>42</value>");
}
[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) static byte[] BuildResources((string Key, object Value)[] entries)
{ {
var ms = new MemoryStream(); var ms = new MemoryStream();

2
ILSpy/Commands/FilePickers.cs

@ -64,7 +64,7 @@ namespace ILSpy.Commands
} }
/// <summary>"PNG (*.png)|*.png|All files|*.*" → two file types.</summary> /// <summary>"PNG (*.png)|*.png|All files|*.*" → two file types.</summary>
static IReadOnlyList<FilePickerFileType> ParseFilter(string filter) internal static IReadOnlyList<FilePickerFileType> ParseFilter(string filter)
{ {
var result = new List<FilePickerFileType>(); var result = new List<FilePickerFileType>();
var parts = filter.Split('|'); var parts = filter.Split('|');

4
ILSpy/TreeNodes/ResourceTreeNode.cs

@ -57,7 +57,9 @@ namespace ILSpy.TreeNodes
if (output is ISmartTextOutput smart) if (output is ISmartTextOutput smart)
{ {
smart.WriteLine(); 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(); smart.WriteLine();
} }
} }

53
ILSpy/TreeNodes/ResourcesFileTreeNode.cs

@ -23,10 +23,12 @@ using System.IO;
using System.Linq; using System.Linq;
using ICSharpCode.Decompiler; using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Util; using ICSharpCode.Decompiler.Util;
using ICSharpCode.ILSpyX.Abstractions; using ICSharpCode.ILSpyX.Abstractions;
using ILSpy.Commands;
using ILSpy.Controls; using ILSpy.Controls;
using ILSpy.Languages; using ILSpy.Languages;
using ILSpy.TextView; 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);
}
}
/// <summary>
/// Re-emits the underlying <c>.resources</c> stream as a ResX (XML) document into
/// <paramref name="target"/>. Skips entries that fail to deserialize so the export still
/// produces a usable file when one entry is malformed.
/// </summary>
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) public override void Decompile(Language language, ITextOutput output, DecompilationOptions options)
{ {
EnsureLazyChildren(); EnsureLazyChildren();

Loading…
Cancel
Save