From 925c52e5f3e999d49aeb8f0eef6bb24497b194e1 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 3 May 2026 17:13:23 +0200 Subject: [PATCH] Wire main-menu Save Code to full decompile + taskbar progress Mirrors WPF SaveCodeContextMenuEntry.Execute: when a single tree node is selected, call its Save() override first (resource nodes already self-handle the dialog + raw/ResX choice). Otherwise prompt for a path with the active language's extension and re-decompile the node with FullDecompilation = true, EscapeInvalidIdentifiers = true, writing the output as plain text via ICSharpCode.Decompiler.PlainTextOutput on a background task. Assisted-by: Claude:claude-opus-4-7:Claude Code --- ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 74 +++++++++++++++++ ILSpy/Commands/FileCommands.cs | 81 ++++++++++++++++++- ILSpy/Docking/DockWorkspace.cs | 2 + 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index cc4a34c73..91892369d 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -663,4 +663,78 @@ public class AssemblyTreeTests scrollViewer.Offset.Y.Should().BeApproximately(offsetBefore, 1.0, "clicking an already-visible row must not move the viewport"); } + + [AvaloniaTest] + public async Task Save_Code_Command_Dispatches_Single_Selected_Node_Save_Override() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var probe = new SaveProbeNode(); + vm.AssemblyTreeModel.SelectedItem = probe; + + var registry = AppComposition.Current.GetExport(); + var saveCmd = registry.Commands + .Single(c => c.Metadata.Header == nameof(Resources._SaveCode)) + .CreateExport().Value; + saveCmd.Execute(null); + + probe.SaveCalled.Should().BeTrue( + "Save Code on a single ILSpyTreeNode selection must dispatch through ILSpyTreeNode.Save()"); + } + + sealed class SaveProbeNode : ILSpyTreeNode + { + public bool SaveCalled { get; private set; } + public override bool Save() + { + SaveCalled = true; + return true; + } + public override object Text => "SaveProbeNode"; + } + + [AvaloniaTest] + public async Task Save_Code_Command_Writes_Full_Decompilation_To_Picked_Path() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.EnsureLazyChildren(); + var method = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "AsEnumerable"); + vm.AssemblyTreeModel.SelectedItem = method; + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var registry = AppComposition.Current.GetExport(); + var saveCmd = (global::ILSpy.Commands.SaveCommand)registry.Commands + .Single(c => c.Metadata.Header == nameof(Resources._SaveCode)) + .CreateExport().Value; + + var tempFile = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "ilspy-save-" + System.Guid.NewGuid().ToString("N") + ".cs"); + try + { + await saveCmd.SaveCodeAsync(tempFile); + + System.IO.File.Exists(tempFile).Should().BeTrue(); + var contents = await System.IO.File.ReadAllTextAsync(tempFile); + contents.Should().Contain("AsEnumerable", + "the full decompilation should include the selected method's name"); + contents.Should().Contain("return source", + "the method body should be present (FullDecompilation = true)"); + } + finally + { + if (System.IO.File.Exists(tempFile)) + System.IO.File.Delete(tempFile); + } + } } diff --git a/ILSpy/Commands/FileCommands.cs b/ILSpy/Commands/FileCommands.cs index 5c3b24828..bd8abf25e 100644 --- a/ILSpy/Commands/FileCommands.cs +++ b/ILSpy/Commands/FileCommands.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Composition; +using System.IO; using System.Linq; using System.Threading.Tasks; @@ -25,9 +26,14 @@ using global::Avalonia.Controls; using global::Avalonia.Controls.ApplicationLifetimes; using global::Avalonia.Platform.Storage; +using ICSharpCode.Decompiler; using ICSharpCode.ILSpy.Properties; +using ILSpy.AppEnv; using ILSpy.AssemblyTree; +using ILSpy.Docking; +using ILSpy.Languages; +using ILSpy.TreeNodes; using ILSpy.Views; namespace ILSpy.Commands @@ -163,9 +169,80 @@ namespace ILSpy.Commands [ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources._SaveCode), MenuIcon = "Images/Save", MenuCategory = nameof(Resources.Save), MenuOrder = 0, InputGestureText = "Ctrl+S")] [Shared] - sealed class SaveCommand : SimpleCommand + [method: ImportingConstructor] + internal sealed class SaveCommand( + AssemblyTreeModel assemblyTreeModel, + LanguageService languageService) : SimpleCommand { - public override void Execute(object? parameter) => NotImplementedDialog.Show(Resources._SaveCode); + public override bool CanExecute(object? parameter) + => assemblyTreeModel.SelectedItem is ILSpyTreeNode; + + public override async void Execute(object? parameter) + { + 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); + } + + /// + /// 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. + /// + public async 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; } + } } [ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.GeneratePortable), MenuCategory = nameof(Resources.Save))] diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index b413923a6..97a060b0f 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -245,6 +245,8 @@ namespace ILSpy.Docking tab.CurrentNodes = nodes; } + public DecompilerTabPageModel? ActiveDecompilerTab => GetActiveDecompilerTab(); + DecompilerTabPageModel? GetActiveDecompilerTab() { if (factory.Documents?.ActiveDockable is DecompilerTabPageModel active)