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. + } + } + } +}