Browse Source

Fall back to decompile-to-file for right-click Save Code

Right-click Save Code on a node without a Save() override (type,
namespace, member) did nothing: the context-menu entry only called
node.Save() and stopped when it returned false, unlike Ctrl+S which
falls through to a generic decompile-to-file save. The prior version
always had this fallback in both paths; the port dropped it from the
context entry.

Extract the shared "Save() else decompile the node to a single file"
flow into SaveCodeHelper and route both Ctrl+S and the context-menu
entry through it, targeting the clicked node. As before, the fallback
runs in the active decompiler tab with its progress/cancel overlay and
then shows a "decompilation complete" breadcrumb with an Open-folder
button; the default file name derives from the node's text.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
a34a6bf9a9
  1. 84
      ILSpy.Tests/Commands/SaveCodeFallbackTests.cs
  2. 60
      ILSpy/Commands/FileCommands.cs
  3. 7
      ILSpy/Commands/SaveCodeContextMenuEntry.cs
  4. 159
      ILSpy/Commands/SaveCodeHelper.cs

84
ILSpy.Tests/Commands/SaveCodeFallbackTests.cs

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
// 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.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ILSpy.AppEnv;
using ILSpy.Commands;
using ILSpy.Languages;
using ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
/// <summary>
/// Right-click "Save Code" must work for nodes that don't override <see cref="ILSpyTreeNode.Save"/>
/// (types, namespaces, members): those need the same decompile-to-file fallback that the Ctrl+S
/// command has, applied to the clicked node. Previously the context entry only called Save() and
/// did nothing when it returned false.
/// </summary>
[TestFixture]
public class SaveCodeFallbackTests
{
[AvaloniaTest]
public async Task A_TypeTreeNode_Has_No_Custom_Save_So_The_Fallback_Is_Required()
{
var (_, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
Assert.That(typeNode, Is.Not.Null);
typeNode!.Save().Should().BeFalse(
"a TypeTreeNode has no Save() override, so Save Code must fall through to decompile-to-file");
}
[AvaloniaTest]
public async Task SaveCode_Writes_A_Clicked_Type_Node_To_A_File()
{
var (_, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
Assert.That(typeNode, Is.Not.Null);
var language = AppComposition.Current.GetExport<LanguageService>()
.Languages.OfType<CSharpLanguage>().First();
var path = Path.Combine(Path.GetTempPath(), "ILSpySaveType_" + System.Guid.NewGuid().ToString("N") + ".cs");
try
{
await SaveCodeHelper.WriteNodeToFileAsync(typeNode!, language, path);
File.Exists(path).Should().BeTrue();
var contents = await File.ReadAllTextAsync(path);
contents.Should().Contain("Enumerable", "the decompiled type's name must be present");
contents.Should().Contain("AsEnumerable", "the type's members must be decompiled (FullDecompilation)");
}
finally
{
if (File.Exists(path))
File.Delete(path);
}
}
}

60
ILSpy/Commands/FileCommands.cs

@ -220,67 +220,21 @@ namespace ILSpy.Commands @@ -220,67 +220,21 @@ namespace ILSpy.Commands
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node)
return;
// Resource nodes (and any future override) handle their own save format and dialog;
// only fall through to the generic "save code" path when the node says it didn't
// claim the request.
if (node.Save())
return;
var language = languageService.CurrentLanguage;
var defaultName = "output" + language.FileExtension;
var path = await FilePickers.SaveAsync(
$"{language.Name} (*{language.FileExtension})|*{language.FileExtension}|All files|*.*",
defaultName).ConfigureAwait(false);
if (path == null)
return;
await SaveCodeAsync(path).ConfigureAwait(false);
// Shared with the context-menu entry: the node handles its own save (resources, the
// assembly project/single-file picker), else fall through to decompile-to-file.
await SaveCodeHelper.SaveNodeAsync(node, languageService, dockWorkspace).ConfigureAwait(false);
}
/// <summary>
/// Public for tests + scripted callers: re-decompiles the currently selected node with
/// <see cref="DecompilationOptions.FullDecompilation"/> on and writes the output to
/// <paramref name="path"/> as plain text. Drives the taskbar progress while running so
/// long saves give visual feedback.
/// <paramref name="path"/> as plain text, bypassing the file picker.
/// </summary>
public async Task SaveCodeAsync(string path)
public Task SaveCodeAsync(string path)
{
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node)
return;
var taskbar = TryGetExport<TaskbarProgressService>();
var language = languageService.CurrentLanguage;
var options = new DecompilationOptions {
FullDecompilation = true,
EscapeInvalidIdentifiers = true,
};
taskbar?.SetState(TaskbarProgressState.Indeterminate);
try
{
await Task.Run(() => {
using var writer = new StreamWriter(path);
var output = new ICSharpCode.Decompiler.PlainTextOutput(writer);
try
{
node.Decompile(language, output, options);
}
catch (System.OperationCanceledException)
{
writer.WriteLine();
writer.WriteLine("// Decompilation was cancelled.");
}
}).ConfigureAwait(false);
}
finally
{
taskbar?.SetState(TaskbarProgressState.None);
}
}
static T? TryGetExport<T>() where T : class
{
try
{ return AppComposition.Current.GetExport<T>(); }
catch { return null; }
return Task.CompletedTask;
return SaveCodeHelper.WriteNodeToFileAsync(node, languageService.CurrentLanguage, path);
}
}

7
ILSpy/Commands/SaveCodeContextMenuEntry.cs

@ -68,8 +68,11 @@ namespace ILSpy.Commands @@ -68,8 +68,11 @@ namespace ILSpy.Commands
return;
}
if (nodes.Length == 1)
(nodes[0] as ILSpyTreeNode)?.Save();
// Single node: let it claim its own save (resources, the assembly project/single-file
// picker), else fall through to decompile-to-file -- the same path as Ctrl+S. Without
// the fallback, Save Code on a type/namespace/member node (no Save override) did nothing.
if (nodes[0] is ILSpyTreeNode node)
SaveCodeHelper.SaveNodeAsync(node, languageService, dockWorkspace).HandleExceptions();
}
// Visible when exactly one ILSpyTreeNode is selected (the single-node save path), or when

159
ILSpy/Commands/SaveCodeHelper.cs

@ -0,0 +1,159 @@ @@ -0,0 +1,159 @@
// 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;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ICSharpCode.Decompiler;
using ICSharpCode.ILSpy.Properties;
using ILSpy.Docking;
using ILSpy.Languages;
using ILSpy.TextView;
using ILSpy.TreeNodes;
namespace ILSpy.Commands
{
/// <summary>
/// The shared "Save Code" flow for a single tree node, used by both File -> Save Code (Ctrl+S)
/// and the Save Code context-menu entry so they behave identically on the node they target.
/// A node first gets a chance to handle its own save (<see cref="ILSpyTreeNode.Save"/> — e.g.
/// resource nodes write raw bytes, assemblies offer a project/single-file picker); when it
/// declines, fall through to a generic decompile-to-single-file save that, mirroring the prior
/// version, runs in the active decompiler tab with its progress/cancel overlay and then shows a
/// "decompilation complete" breadcrumb (with an Open-folder button).
/// </summary>
internal static class SaveCodeHelper
{
/// <summary>
/// Saves <paramref name="node"/>: lets the node claim the request via its
/// <see cref="ILSpyTreeNode.Save"/> override, otherwise prompts for a file and decompiles the
/// node into it, showing progress (and allowing cancellation) in the active decompiler tab.
/// Does nothing if the user cancels the picker.
/// </summary>
public static async Task SaveNodeAsync(ILSpyTreeNode node, LanguageService languageService, DockWorkspace dockWorkspace)
{
ArgumentNullException.ThrowIfNull(node);
ArgumentNullException.ThrowIfNull(languageService);
ArgumentNullException.ThrowIfNull(dockWorkspace);
if (node.Save())
return;
var language = languageService.CurrentLanguage;
var defaultName = SuggestedFileName(node) + language.FileExtension;
var path = await FilePickers.SaveAsync(
$"{language.Name} (*{language.FileExtension})|*{language.FileExtension}|All files|*.*",
defaultName).ConfigureAwait(true);
if (path == null)
return;
try
{
// Run the decompile-to-file behind the tab's cancellable progress overlay, then show a
// result breadcrumb + Open-folder button -- the same UX the prior version's SaveToDisk had.
var output = await dockWorkspace.RunWithCancellation(token => Task.Run(() => {
var stopwatch = Stopwatch.StartNew();
WriteNodeToFile(node, language, path, token);
stopwatch.Stop();
var o = new AvaloniaEditTextOutput { Title = node.Text?.ToString() ?? string.Empty };
o.WriteLine(string.Format(Resources.DecompilationCompleteInF1Seconds, stopwatch.Elapsed.TotalSeconds));
o.WriteLine();
if (Path.GetDirectoryName(path) is { Length: > 0 } directory)
{
o.AddButton(null, Resources.OpenExplorer, (_, _) => OpenFolder(directory));
o.WriteLine();
}
return o;
}, token), node.Text?.ToString()).ConfigureAwait(true);
dockWorkspace.ShowText(output);
}
catch (OperationCanceledException)
{
// User cancelled -- leave the tab content as it was.
}
}
/// <summary>
/// Re-decompiles <paramref name="node"/> with <see cref="DecompilationOptions.FullDecompilation"/>
/// on and writes the output to <paramref name="path"/> as plain text. Public so callers (and
/// tests) can bypass the file picker and the tab progress UI.
/// </summary>
public static Task WriteNodeToFileAsync(ILSpyTreeNode node, Language language, string path)
=> Task.Run(() => WriteNodeToFile(node, language, path, CancellationToken.None));
static void WriteNodeToFile(ILSpyTreeNode node, Language language, string path, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(node);
ArgumentNullException.ThrowIfNull(language);
var options = new DecompilationOptions {
FullDecompilation = true,
EscapeInvalidIdentifiers = true,
CancellationToken = ct,
};
using var writer = new StreamWriter(path);
var output = new PlainTextOutput(writer);
try
{
node.Decompile(language, output, options);
}
catch (OperationCanceledException)
{
writer.WriteLine();
writer.WriteLine("// " + Resources.DecompilationWasCancelled);
throw;
}
}
// A reasonable default file name for the save dialog: the node's text, cleaned of characters
// that aren't valid in a file name; falls back to "output" when nothing usable remains.
static string SuggestedFileName(ILSpyTreeNode node)
{
var text = node.Text?.ToString();
if (string.IsNullOrWhiteSpace(text))
return "output";
var clean = text;
foreach (var c in Path.GetInvalidFileNameChars())
clean = clean.Replace(c, '_');
clean = clean.Trim();
return string.IsNullOrEmpty(clean) ? "output" : clean;
}
static void OpenFolder(string path)
{
try
{
if (OperatingSystem.IsWindows())
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = false });
else if (OperatingSystem.IsMacOS())
Process.Start(new ProcessStartInfo("open", $"\"{path}\"") { UseShellExecute = false });
else
Process.Start(new ProcessStartInfo("xdg-open", path) { UseShellExecute = false });
}
catch
{
// Best-effort.
}
}
}
}
Loading…
Cancel
Save