From 899f77dff9d62cdee519e96aa1cdcd51cfe5aff0 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 14 May 2026 22:59:50 +0200 Subject: [PATCH] =?UTF-8?q?Create=20Diagram=20context-menu=20entry=20?= =?UTF-8?q?=E2=80=94=20HTML=20class=20diagrammer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click an assembly tree node → Create Diagram. Prompts for an output folder via the new FilePickers.PickFolderAsync helper, then runs the shared ICSharpCode.ILSpyX.MermaidDiagrammer.GenerateHtmlDiagrammer engine on a background thread under DockWorkspace.RunWithCancellation (the new wait UI with a custom "Creating diagram…" title). On completion, pushes a report into the active tab via ShowText — elapsed time, learn-more link, and an "Open Explorer" button that selects the generated index.html in the OS file manager (xdg-open / open -R / explorer.exe per platform). Assisted-by: Claude:claude-opus-4-7:Claude Code --- .../CreateDiagramContextMenuTests.cs | 95 ++++++++++++ .../Commands/CreateDiagramContextMenuEntry.cs | 140 ++++++++++++++++++ ILSpy/Commands/FilePickers.cs | 22 +++ 3 files changed, 257 insertions(+) create mode 100644 ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs create mode 100644 ILSpy/Commands/CreateDiagramContextMenuEntry.cs diff --git a/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs b/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs new file mode 100644 index 000000000..fd11a6b51 --- /dev/null +++ b/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs @@ -0,0 +1,95 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Commands; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class CreateDiagramContextMenuTests +{ + [AvaloniaTest] + public async Task Entry_Is_Visible_For_A_Single_Loaded_Assembly() + { + // The "Create Diagram" context-menu entry must surface when the user right-clicks + // exactly one assembly tree node whose underlying file loaded as a valid assembly. + // Multi-select, child-node selection, and failed-load nodes should all hide it — + // the diagrammer needs a single concrete assembly path to feed to GenerateHtmlDiagrammer. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var entry = AppComposition.Current.GetExport().Entries + .Select(e => e.Value).OfType().Single(); + + var assemblyNode = vm.AssemblyTreeModel.Root!.Children.OfType().First(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { assemblyNode } }).Should().BeTrue(); + } + + [AvaloniaTest] + public async Task Entry_Is_Hidden_For_Non_Assembly_Selections() + { + // Anything that isn't an AssemblyTreeNode (e.g. a TypeTreeNode under it) doesn't + // expose the entry — the diagrammer operates on whole-assembly granularity. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var entry = AppComposition.Current.GetExport().Entries + .Select(e => e.Value).OfType().Single(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } }).Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Entry_Is_Hidden_For_Multi_Select() + { + // Multi-select must also hide the entry — the diagrammer runs on one assembly at + // a time. (The WPF version uses the same `Length == 1` guard.) + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var entry = AppComposition.Current.GetExport().Entries + .Select(e => e.Value).OfType().Single(); + + var assemblyNode = vm.AssemblyTreeModel.Root!.Children.OfType().First(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { assemblyNode, assemblyNode } }).Should().BeFalse(); + } +} diff --git a/ILSpy/Commands/CreateDiagramContextMenuEntry.cs b/ILSpy/Commands/CreateDiagramContextMenuEntry.cs new file mode 100644 index 000000000..393efc861 --- /dev/null +++ b/ILSpy/Commands/CreateDiagramContextMenuEntry.cs @@ -0,0 +1,140 @@ +// 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.Composition; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.MermaidDiagrammer; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.Docking; +using ILSpy.TextView; +using ILSpy.TreeNodes; + +namespace ILSpy.Commands +{ + /// + /// Right-click an assembly → "Create Diagram". Asks for an output folder, invokes the + /// shared engine on a background thread, then + /// pushes a completion report (elapsed time, learn-more link, Open-Explorer button) + /// into the active decompiler tab via . Visible + /// only when exactly one valid loaded assembly is selected. + /// + [ExportContextMenuEntry(Header = nameof(Resources._CreateDiagram), Category = nameof(Resources.Save), Icon = "Images/Save")] + [Shared] + public sealed class CreateDiagramContextMenuEntry : IContextMenuEntry + { + readonly DockWorkspace dockWorkspace; + + [ImportingConstructor] + public CreateDiagramContextMenuEntry(DockWorkspace dockWorkspace) + { + this.dockWorkspace = dockWorkspace; + } + + public bool IsVisible(TextViewContext context) + { + return context.SelectedTreeNodes?.Length == 1 + && context.SelectedTreeNodes[0] is AssemblyTreeNode tn + && tn.LoadedAssembly.IsLoadedAsValidAssembly; + } + + public bool IsEnabled(TextViewContext context) => true; + + public void Execute(TextViewContext context) + { + var assembly = (context.SelectedTreeNodes?.FirstOrDefault() as AssemblyTreeNode)?.LoadedAssembly; + if (assembly == null) + return; + _ = ExecuteAsync(assembly.FileName); + } + + async Task ExecuteAsync(string assemblyFile) + { + var outputFolder = await FilePickers.PickFolderAsync("Select target folder"); + if (string.IsNullOrEmpty(outputFolder)) + return; + + try + { + var output = await dockWorkspace.RunWithCancellation( + token => Task.Run(() => RunGenerator(assemblyFile, outputFolder), token), + Resources.CreatingDiagram); + dockWorkspace.ShowText(output); + } + catch (OperationCanceledException) + { + // User cancelled — leave the previous tab content visible. + } + } + + static AvaloniaEditTextOutput RunGenerator(string assemblyFile, string outputFolder) + { + var output = new AvaloniaEditTextOutput(); + var stopwatch = Stopwatch.StartNew(); + var command = new GenerateHtmlDiagrammer { + Assembly = assemblyFile, + OutputFolder = outputFolder, + }; + command.Run(); + stopwatch.Stop(); + output.Title = "Create Diagram"; + output.Write(string.Format(Resources.GenerationCompleteInSeconds, stopwatch.Elapsed.TotalSeconds.ToString("F1"))); + output.WriteLine(); + output.WriteLine(); + output.Write("Learn more: https://github.com/icsharpcode/ILSpy/wiki/Diagramming#tips-for-using-the-html-diagrammer"); + output.WriteLine(); + output.WriteLine(); + var diagramHtml = Path.Combine(outputFolder, "index.html"); + output.AddButton(null, Resources.OpenExplorer, (_, _) => OpenInShell(diagramHtml)); + output.WriteLine(); + return output; + } + + static void OpenInShell(string path) + { + try + { + if (OperatingSystem.IsWindows()) + { + Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { UseShellExecute = false }); + } + else if (OperatingSystem.IsMacOS()) + { + Process.Start(new ProcessStartInfo("open", $"-R \"{path}\"") { UseShellExecute = false }); + } + else + { + // Linux: no universal "select item" command; open the parent directory instead. + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + Process.Start(new ProcessStartInfo("xdg-open", parent) { UseShellExecute = false }); + } + } + catch + { + // Best-effort: the user can navigate to the folder manually if the shell call fails. + } + } + } +} diff --git a/ILSpy/Commands/FilePickers.cs b/ILSpy/Commands/FilePickers.cs index f30084fe2..d9bb2cfb8 100644 --- a/ILSpy/Commands/FilePickers.cs +++ b/ILSpy/Commands/FilePickers.cs @@ -63,6 +63,28 @@ namespace ILSpy.Commands return file?.TryGetLocalPath(); } + /// + /// Shows a folder-picker dialog. appears in the dialog + /// chrome. Returns the selected folder's absolute path, or null if the user + /// cancelled or the storage provider refused to give a local path (e.g. cloud + /// folder). + /// + public static async Task PickFolderAsync(string? title = null) + { + var owner = (global::Avalonia.Application.Current?.ApplicationLifetime + as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (owner == null) + return null; + + var folders = await owner.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { + Title = title, + AllowMultiple = false, + }); + if (folders.Count == 0) + return null; + return folders[0].TryGetLocalPath(); + } + /// "PNG (*.png)|*.png|All files|*.*" → two file types. internal static IReadOnlyList ParseFilter(string filter) {