Browse Source

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
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
925c52e5f3
  1. 74
      ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs
  2. 81
      ILSpy/Commands/FileCommands.cs
  3. 2
      ILSpy/Docking/DockWorkspace.cs

74
ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs

@ -663,4 +663,78 @@ public class AssemblyTreeTests @@ -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<MainWindow>();
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<MainMenuCommandRegistry>();
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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
typeNode.EnsureLazyChildren();
var method = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "AsEnumerable");
vm.AssemblyTreeModel.SelectedItem = method;
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
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);
}
}
}

81
ILSpy/Commands/FileCommands.cs

@ -18,6 +18,7 @@ @@ -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; @@ -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 @@ -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);
}
/// <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.
/// </summary>
public async 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; }
}
}
[ExportMainMenuCommand(ParentMenuID = nameof(Resources._File), Header = nameof(Resources.GeneratePortable), MenuCategory = nameof(Resources.Save))]

2
ILSpy/Docking/DockWorkspace.cs

@ -245,6 +245,8 @@ namespace ILSpy.Docking @@ -245,6 +245,8 @@ namespace ILSpy.Docking
tab.CurrentNodes = nodes;
}
public DecompilerTabPageModel? ActiveDecompilerTab => GetActiveDecompilerTab();
DecompilerTabPageModel? GetActiveDecompilerTab()
{
if (factory.Documents?.ActiveDockable is DecompilerTabPageModel active)

Loading…
Cancel
Save