mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3755/head
2 changed files with 234 additions and 0 deletions
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue