Browse Source

Open-containing-folder context-menu entry

Reveals the assembly's file in the OS file manager. Walks the selection's
parent chain to the enclosing AssemblyTreeNode so the entry works on
members / namespaces too, not just the assembly row.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
7c7e30cd4d
  1. 115
      ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs
  2. 119
      ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs

115
ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs

@ -0,0 +1,115 @@ @@ -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<MainWindow>();
window.Show();
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
// 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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3);
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = (OpenContainingFolderContextMenuEntry)registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder))
.Value;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder))
.Value;
var realAssemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("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<SharpTreeNode>() })
.Should().BeFalse();
}
}

119
ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs

@ -0,0 +1,119 @@ @@ -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
{
/// <summary>
/// Right-click → "Open Containing Folder". Walks up the selection's parent chain to the
/// enclosing <see cref="AssemblyTreeNode"/> (so it works on members and namespaces, not
/// just the assembly row itself) and asks the OS file manager to reveal that file.
/// </summary>
[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);
}
/// <summary>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.</summary>
public IReadOnlyList<string> GetPathsToReveal(TextViewContext context)
{
if (context.SelectedTreeNodes is not { Length: > 0 } nodes)
return System.Array.Empty<string>();
var paths = new List<string>(nodes.Length);
foreach (var node in nodes)
{
var assembly = FindEnclosingAssemblyNode(node);
if (assembly is null)
return System.Array.Empty<string>();
var path = assembly.LoadedAssembly.FileName;
if (!File.Exists(path))
return System.Array.Empty<string>();
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.
}
}
}
}
Loading…
Cancel
Save