diff --git a/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs b/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs new file mode 100644 index 000000000..003d265dc --- /dev/null +++ b/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs @@ -0,0 +1,115 @@ +// 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.ILSpy.Properties; +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 OpenContainingFolderTests +{ + [AvaloniaTest] + public async Task OpenContainingFolder_Entry_Is_Registered() + { + // AssemblyTreeNode contributes a [ExportContextMenuEntry] under the "Shell" category + // for revealing the assembly's file in the OS file manager. + + // Arrange + Act — boot. + var window = AppComposition.Current.GetExport(); + window.Show(); + var registry = AppComposition.Current.GetExport(); + + // Assert — registry contains the entry. + registry.Entries.Should().Contain( + e => e.Metadata.Header == nameof(Resources._OpenContainingFolder)); + } + + [AvaloniaTest] + public async Task OpenContainingFolder_Walks_Up_To_The_Containing_AssemblyTreeNode() + { + // The reveal works on any descendant of an AssemblyTreeNode (member, type, namespace), + // not just the assembly node itself. Verifies the helper walks the parent chain to + // find the enclosing assembly and uses its file path. + + // Arrange — boot, find a deep tree node + the registered entry. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + var registry = AppComposition.Current.GetExport(); + var entry = (OpenContainingFolderContextMenuEntry)registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder)) + .Value; + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + var expectedPath = assemblyNode.LoadedAssembly.FileName; + + // Act — collect the paths the entry would reveal for a deep selection. + var paths = entry.GetPathsToReveal(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }); + + // Assert — the deep selection traces back to the parent assembly's file path. + paths.Should().Equal(expectedPath); + } + + [AvaloniaTest] + public async Task OpenContainingFolder_Is_Hidden_When_The_File_No_Longer_Exists() + { + // IsVisible filters out selections whose backing file isn't on disk anymore — there's + // nothing to reveal. The entry stays hidden so the menu doesn't dangle a non-functional + // row. + + // Arrange — boot, locate entry, build a fake AssemblyTreeNode pointing at a missing file. + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder)) + .Value; + + var realAssemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); + + // Assert — visible when the file exists, hidden when nothing is selected. + entry.IsVisible(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)realAssemblyNode } }) + .Should().BeTrue(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = null }) + .Should().BeFalse(); + entry.IsVisible(new TextViewContext { SelectedTreeNodes = System.Array.Empty() }) + .Should().BeFalse(); + } +} diff --git a/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs b/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs new file mode 100644 index 000000000..46b3de888 --- /dev/null +++ b/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs @@ -0,0 +1,119 @@ +// 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.Collections.Generic; +using System.Composition; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpyX.TreeView; + +using ILSpy.TreeNodes; + +namespace ILSpy.Commands +{ + /// + /// Right-click → "Open Containing Folder". Walks up the selection's parent chain to the + /// enclosing (so it works on members and namespaces, not + /// just the assembly row itself) and asks the OS file manager to reveal that file. + /// + [ExportContextMenuEntry(Header = nameof(Resources._OpenContainingFolder), Category = nameof(Resources.Shell))] + [Shared] + public sealed class OpenContainingFolderContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) => GetPathsToReveal(context).Count > 0; + + public bool IsEnabled(TextViewContext context) => GetPathsToReveal(context).Count > 0; + + public void Execute(TextViewContext context) + { + foreach (var path in GetPathsToReveal(context)) + RevealInFileManager(path); + } + + /// Public for tests: returns the on-disk file paths the reveal would target, + /// in selection order. Each path is guaranteed to exist; deep selections trace up to + /// the enclosing assembly. + public IReadOnlyList GetPathsToReveal(TextViewContext context) + { + if (context.SelectedTreeNodes is not { Length: > 0 } nodes) + return System.Array.Empty(); + var paths = new List(nodes.Length); + foreach (var node in nodes) + { + var assembly = FindEnclosingAssemblyNode(node); + if (assembly is null) + return System.Array.Empty(); + var path = assembly.LoadedAssembly.FileName; + if (!File.Exists(path)) + return System.Array.Empty(); + paths.Add(path); + } + return paths; + } + + static AssemblyTreeNode? FindEnclosingAssemblyNode(SharpTreeNode? node) + { + while (node != null) + { + if (node is AssemblyTreeNode a) + return a; + node = node.Parent; + } + return null; + } + + static void RevealInFileManager(string path) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows: explorer.exe /select highlights the file inside the folder. + Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"") { + UseShellExecute = false, + }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // macOS: -R reveals the file in Finder. + Process.Start(new ProcessStartInfo("open", $"-R \"{path}\"") { + UseShellExecute = false, + }); + } + else + { + // Linux + others: open the parent directory; most file managers don't + // have a stable cross-distro "select-file" hook. + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Process.Start(new ProcessStartInfo("xdg-open", $"\"{dir}\"") { + UseShellExecute = false, + }); + } + } + catch + { + // Failure to launch the shell is non-fatal — the menu item already returned. + } + } + } +}