diff --git a/ILSpy.Tests/Commands/SaveCodeFallbackTests.cs b/ILSpy.Tests/Commands/SaveCodeFallbackTests.cs
new file mode 100644
index 000000000..2fd45e0aa
--- /dev/null
+++ b/ILSpy.Tests/Commands/SaveCodeFallbackTests.cs
@@ -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;
+
+///
+/// Right-click "Save Code" must work for nodes that don't override
+/// (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.
+///
+[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(
+ "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(
+ "System.Linq", "System.Linq", "System.Linq.Enumerable");
+ Assert.That(typeNode, Is.Not.Null);
+
+ var language = AppComposition.Current.GetExport()
+ .Languages.OfType().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);
+ }
+ }
+}
diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs
index 07625c736..d6e069922 100644
--- a/ILSpy/Commands/FileCommands.cs
+++ b/ILSpy/Commands/FileCommands.cs
@@ -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);
}
///
/// Public for tests + scripted callers: re-decompiles the currently selected node with
/// on and writes the output to
- /// as plain text. Drives the taskbar progress while running so
- /// long saves give visual feedback.
+ /// as plain text, bypassing the file picker.
///
- public async Task SaveCodeAsync(string path)
+ public Task SaveCodeAsync(string path)
{
if (assemblyTreeModel.SelectedItem is not ILSpyTreeNode node)
- return;
-
- var taskbar = TryGetExport();
- 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() where T : class
- {
- try
- { return AppComposition.Current.GetExport(); }
- catch { return null; }
+ return Task.CompletedTask;
+ return SaveCodeHelper.WriteNodeToFileAsync(node, languageService.CurrentLanguage, path);
}
}
diff --git a/ILSpy/Commands/SaveCodeContextMenuEntry.cs b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
index de1d9a832..80e8a63a1 100644
--- a/ILSpy/Commands/SaveCodeContextMenuEntry.cs
+++ b/ILSpy/Commands/SaveCodeContextMenuEntry.cs
@@ -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
diff --git a/ILSpy/Commands/SaveCodeHelper.cs b/ILSpy/Commands/SaveCodeHelper.cs
new file mode 100644
index 000000000..2c6d93e24
--- /dev/null
+++ b/ILSpy/Commands/SaveCodeHelper.cs
@@ -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
+{
+ ///
+ /// 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 ( — 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).
+ ///
+ internal static class SaveCodeHelper
+ {
+ ///
+ /// Saves : lets the node claim the request via its
+ /// 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.
+ ///
+ 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.
+ }
+ }
+
+ ///
+ /// Re-decompiles with
+ /// on and writes the output to as plain text. Public so callers (and
+ /// tests) can bypass the file picker and the tab progress UI.
+ ///
+ 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.
+ }
+ }
+ }
+}