diff --git a/ILSpy.Tests/Util/ShellHelperTests.cs b/ILSpy.Tests/Util/ShellHelperTests.cs new file mode 100644 index 000000000..d7747dc49 --- /dev/null +++ b/ILSpy.Tests/Util/ShellHelperTests.cs @@ -0,0 +1,102 @@ +// 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.IO; +using System.Linq; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Util; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class ShellHelperTests +{ + // Revealing several selected assemblies must collapse to one file-manager window per + // containing folder (the WPF SHOpenFolderAndSelectItems behaviour), not one window per + // file. GroupByFolder is the pure seam that produces those groups. + + static string Combine(params string[] parts) => Path.Combine(parts); + + [Test] + public void Files_In_The_Same_Folder_Form_A_Single_Group() + { + var dir = Combine("root", "bin"); + var a = Combine(dir, "A.dll"); + var b = Combine(dir, "B.dll"); + + var groups = ShellHelper.GroupByFolder(new[] { a, b }); + + groups.Should().HaveCount(1); + groups[0].Folder.Should().Be(dir); + groups[0].Files.Should().Equal(a, b); + } + + [Test] + public void Files_In_Different_Folders_Form_Separate_Groups_In_First_Seen_Order() + { + var dir1 = Combine("root", "one"); + var dir2 = Combine("root", "two"); + var a = Combine(dir1, "A.dll"); + var b = Combine(dir2, "B.dll"); + var c = Combine(dir1, "C.dll"); + + var groups = ShellHelper.GroupByFolder(new[] { a, b, c }); + + // dir1 seen first; C.dll joins dir1's existing group rather than starting a new one. + groups.Select(g => g.Folder).Should().Equal(dir1, dir2); + groups[0].Files.Should().Equal(a, c); + groups[1].Files.Should().Equal(b); + } + + [Test] + public void Duplicate_Paths_Are_Removed_Case_Insensitively() + { + var dir = Combine("root", "bin"); + var a = Combine(dir, "A.dll"); + + var groups = ShellHelper.GroupByFolder(new[] { a, a.ToUpperInvariant() }); + + groups.Should().HaveCount(1); + groups[0].Files.Should().Equal(a); + } + + [Test] + public void Empty_And_Directory_Less_Entries_Are_Skipped() + { + var dir = Combine("root", "bin"); + var a = Combine(dir, "A.dll"); + + // null, empty, and a bare file name (no containing directory) are dropped. + var groups = ShellHelper.GroupByFolder(new[] { null, "", "bare.dll", a }); + + groups.Should().HaveCount(1); + groups[0].Folder.Should().Be(dir); + groups[0].Files.Should().Equal(a); + } + + [Test] + public void No_Usable_Paths_Yields_No_Groups() + { + ShellHelper.GroupByFolder(System.Array.Empty()).Should().BeEmpty(); + ShellHelper.GroupByFolder(null!).Should().BeEmpty(); + } +} diff --git a/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs b/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs index 6dd226909..25161ac33 100644 --- a/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs +++ b/ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs @@ -46,8 +46,9 @@ namespace ICSharpCode.ILSpy.Commands public void Execute(TextViewContext context) { - foreach (var path in GetPathsToReveal(context)) - ShellHelper.RevealFile(path); + // Reveal all selected files in one grouped call so that several assemblies in the same + // folder open a single Explorer window (with them selected) rather than one per file. + ShellHelper.RevealFiles(GetPathsToReveal(context)); } /// Public for tests: returns the on-disk file paths the reveal would target, diff --git a/ILSpy/Util/ShellHelper.Windows.cs b/ILSpy/Util/ShellHelper.Windows.cs new file mode 100644 index 000000000..7b9b65f9f --- /dev/null +++ b/ILSpy/Util/ShellHelper.Windows.cs @@ -0,0 +1,125 @@ +// Copyright (c) 2025 sonyps5201314 +// +// 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. + +// The shell-COM reveal below (SHParseDisplayName + SHOpenFolderAndSelectItems) is adapted from +// the pre-Avalonia, Windows-only ShellHelper contributed by sonyps5201314; its copyright notice +// is retained per the MIT license under which it was provided. It is invoked only on Windows +// (guarded by OperatingSystem.IsWindows() in RevealFiles); the P/Invokes resolve lazily, so the +// declarations are harmless to compile on the cross-platform target. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +#pragma warning disable CA1060 // Move pinvokes to native methods class + +namespace ICSharpCode.ILSpy.Util +{ + static partial class ShellHelper + { + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial int SHParseDisplayName(string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut); + + [LibraryImport("shell32.dll")] + private static partial int SHOpenFolderAndSelectItems(IntPtr pidlFolder, uint cidl, ReadOnlySpan apidl, uint dwFlags); + + [LibraryImport("shell32.dll")] + private static partial IntPtr ILFindLastID(IntPtr pidl); + + [LibraryImport("ole32.dll")] + private static partial void CoTaskMemFree(IntPtr pv); + + /// + /// Opens in Explorer with all of selected, + /// reusing a window already open at that folder. Falls back to explorer.exe /select, + /// (single item) and then to just opening the folder if the shell COM call fails. + /// + static void RevealInExplorer(string folder, IReadOnlyList files) + { + IntPtr folderPidl = IntPtr.Zero; + var itemPidlAllocs = new List(); + var relativePidls = new List(); + + try + { + int hrFolder = SHParseDisplayName(folder, IntPtr.Zero, out folderPidl, 0, out _); + Marshal.ThrowExceptionForHR(hrFolder); + + foreach (var file in files) + { + int hrItem = SHParseDisplayName(file, IntPtr.Zero, out var itemPidl, 0, out _); + if (hrItem == 0 && itemPidl != IntPtr.Zero) + { + // ILFindLastID returns a pointer *into* itemPidl, so itemPidl must stay alive + // until SHOpenFolderAndSelectItems has consumed it (freed in the finally). + IntPtr relative = ILFindLastID(itemPidl); + if (relative != IntPtr.Zero) + { + relativePidls.Add(relative); + itemPidlAllocs.Add(itemPidl); + continue; + } + } + if (itemPidl != IntPtr.Zero) + CoTaskMemFree(itemPidl); + } + + if (relativePidls.Count > 0) + { + int hr = SHOpenFolderAndSelectItems(folderPidl, (uint)relativePidls.Count, CollectionsMarshal.AsSpan(relativePidls), 0); + Marshal.ThrowExceptionForHR(hr); + } + else + { + // Nothing resolved to a selectable item: just open the folder. + OpenFolder(folder); + } + } + catch (Exception ex) when (ex is COMException or Win32Exception) + { + RevealWithExplorerSelect(folder, files); + } + finally + { + foreach (var p in itemPidlAllocs) + CoTaskMemFree(p); + if (folderPidl != IntPtr.Zero) + CoTaskMemFree(folderPidl); + } + } + + // Primitive fallback when the shell COM call is unavailable: explorer.exe can only select a + // single item from the command line, so reveal the first file (or open the folder). + static void RevealWithExplorerSelect(string folder, IReadOnlyList files) + { + try + { + if (files.Count > 0) + Process.Start(new ProcessStartInfo("explorer.exe", $"/select,\"{files[0]}\"") { UseShellExecute = false }); + else + OpenFolder(folder); + } + catch + { + // Best-effort: the user can navigate manually if even this fails. + } + } + } +} diff --git a/ILSpy/Util/ShellHelper.cs b/ILSpy/Util/ShellHelper.cs index 5ef3b5c60..44c08bc53 100644 --- a/ILSpy/Util/ShellHelper.cs +++ b/ILSpy/Util/ShellHelper.cs @@ -17,29 +17,92 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; + +// The Windows-only part of this partial class declares shell32/ole32 P/Invokes; CA1060 reports on +// the class declaration here in the primary file rather than at the DllImport site. +#pragma warning disable CA1060 // Move pinvokes to native methods class namespace ICSharpCode.ILSpy.Util { /// - /// Cross-platform helpers for handing a path to the OS shell: open a folder, reveal a file in - /// the file manager, or open a file with its default application. All calls are best-effort -- - /// a failed launch is swallowed, since the user can navigate manually and the gesture has - /// already returned. Consolidates the explorer.exe / open / xdg-open switch that was copied - /// across the export, save, PDB, diagram and package-extraction commands. + /// Cross-platform helpers for handing a path to the OS shell: open a folder, reveal one or more + /// files in the file manager, or open a file with its default application. All calls are + /// best-effort -- a failed launch is swallowed, since the user can navigate manually and the + /// gesture has already returned. Consolidates the explorer.exe / open / xdg-open switch that was + /// copied across the export, save, PDB, diagram and package-extraction commands. + /// + /// On Windows, revealing files goes through the shell COM API (see the Windows-specific part of + /// this class) so that several selected files collapse into a single Explorer window -- reusing + /// one already open at that folder -- instead of spawning a fresh explorer.exe per file. /// - public static class ShellHelper + public static partial class ShellHelper { /// Opens (a directory) in the OS file manager. - public static void OpenFolder(string path) => Launch(path, selectItem: false); + public static void OpenFolder(string path) + { + try + { + if (OperatingSystem.IsWindows()) + Process.Start(new ProcessStartInfo("explorer.exe", Quote(path)) { UseShellExecute = false }); + else if (OperatingSystem.IsMacOS()) + Process.Start(new ProcessStartInfo("open", Quote(path)) { UseShellExecute = false }); + else + Process.Start(new ProcessStartInfo("xdg-open", path) { UseShellExecute = false }); + } + catch + { + // Best-effort: the user can navigate manually if the shell call fails. + } + } /// /// Reveals (a file) in the OS file manager, selecting it where the - /// platform supports it (Windows /select,, macOS -R). On Linux there is no - /// stable cross-distro "select file" hook, so the parent directory is opened instead. + /// platform supports it. See for the multi-file behaviour. + /// + public static void RevealFile(string path) => RevealFiles(new[] { path }); + + /// + /// Reveals several files in the OS file manager. Files are grouped by containing folder so + /// that each folder is shown in a single window with all of its files selected, rather than + /// one window per file. On Windows this reuses an Explorer window already open at the folder + /// (shell COM); on macOS Finder's open -R reveals and selects; on Linux there is no + /// portable "select item" hook, so each distinct parent folder is opened once. /// - public static void RevealFile(string path) => Launch(path, selectItem: true); + public static void RevealFiles(IEnumerable paths) + { + var groups = GroupByFolder(paths); + if (groups.Count == 0) + return; + + if (OperatingSystem.IsWindows()) + { + foreach (var (folder, files) in groups) + RevealInExplorer(folder, files); + } + else if (OperatingSystem.IsMacOS()) + { + // Finder reveals and selects every passed file in one invocation. + var allFiles = groups.SelectMany(g => g.Files).Select(Quote); + try + { + Process.Start(new ProcessStartInfo("open", "-R " + string.Join(' ', allFiles)) { UseShellExecute = false }); + } + catch + { + // Best-effort: fall through silently. + } + } + else + { + // Linux + others: open each distinct parent folder once (deduped by GroupByFolder). + foreach (var (folder, _) in groups) + OpenFolder(folder); + } + } /// Opens with its default application (image viewer, /// browser, ...). @@ -59,32 +122,40 @@ namespace ICSharpCode.ILSpy.Util } } - static void Launch(string path, bool selectItem) + /// + /// Groups paths by their containing directory, preserving the order in which folders are + /// first seen and deduping paths case-insensitively. Entries that are null/empty or have no + /// containing directory are dropped. Exposed for testing the reveal grouping without + /// launching the OS file manager. + /// + internal static IReadOnlyList<(string Folder, IReadOnlyList Files)> GroupByFolder(IEnumerable? paths) { - try + if (paths is null) + return Array.Empty<(string, IReadOnlyList)>(); + + var groups = new List<(string Folder, List Files)>(); + var folderIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var path in paths) { - if (OperatingSystem.IsWindows()) + if (string.IsNullOrEmpty(path) || !seen.Add(path)) + continue; + var folder = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(folder)) + continue; + if (!folderIndex.TryGetValue(folder, out int i)) { - var args = selectItem ? $"/select,\"{path}\"" : $"\"{path}\""; - Process.Start(new ProcessStartInfo("explorer.exe", args) { UseShellExecute = false }); - } - else if (OperatingSystem.IsMacOS()) - { - var args = selectItem ? $"-R \"{path}\"" : $"\"{path}\""; - Process.Start(new ProcessStartInfo("open", args) { UseShellExecute = false }); - } - else - { - // Linux + others: no universal "select item" command, so revealing a file opens - // its parent directory. - var target = selectItem ? (Path.GetDirectoryName(path) ?? path) : path; - Process.Start(new ProcessStartInfo("xdg-open", target) { UseShellExecute = false }); + i = groups.Count; + folderIndex.Add(folder, i); + groups.Add((folder, new List())); } + groups[i].Files.Add(path); } - catch - { - // Best-effort: the user can navigate manually if the shell call fails. - } + + return groups.Select(g => (g.Folder, (IReadOnlyList)g.Files)).ToList(); } + + static string Quote(string value) => "\"" + value + "\""; } }