Browse Source

Reveal selected files in a single Explorer window again

WPF revealed files through the shell COM API (SHOpenFolderAndSelectItems):
selecting several assemblies and choosing "Open Containing Folder" opened
one Explorer window per folder with all of that folder's files selected,
reusing a window already open at the location. The Avalonia port replaced
this with explorer.exe /select, invoked once per file, so revealing N
assemblies spawned N new windows and a single reveal never reused an
existing one.

Restore the shell-COM reveal on Windows behind the existing cross-platform
ShellHelper, grouping the selection by containing folder. macOS keeps a
single Finder "open -R" for the whole selection; Linux, lacking a portable
select-item hook, opens each distinct parent folder once instead of one
window per file. The folder grouping is a pure, unit-tested seam so the
behaviour is verified without launching the OS file manager.

The Windows COM portion is adapted from the pre-Avalonia ShellHelper; its
author's copyright notice is retained per its MIT license.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3404/head
Siegfried Pammer 4 weeks ago
parent
commit
7d975ef845
  1. 102
      ILSpy.Tests/Util/ShellHelperTests.cs
  2. 5
      ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs
  3. 125
      ILSpy/Util/ShellHelper.Windows.cs
  4. 133
      ILSpy/Util/ShellHelper.cs

102
ILSpy.Tests/Util/ShellHelperTests.cs

@ -0,0 +1,102 @@ @@ -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<string>()).Should().BeEmpty();
ShellHelper.GroupByFolder(null!).Should().BeEmpty();
}
}

5
ILSpy/Commands/OpenContainingFolderContextMenuEntry.cs

@ -46,8 +46,9 @@ namespace ICSharpCode.ILSpy.Commands @@ -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));
}
/// <summary>Public for tests: returns the on-disk file paths the reveal would target,

125
ILSpy/Util/ShellHelper.Windows.cs

@ -0,0 +1,125 @@ @@ -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<IntPtr> apidl, uint dwFlags);
[LibraryImport("shell32.dll")]
private static partial IntPtr ILFindLastID(IntPtr pidl);
[LibraryImport("ole32.dll")]
private static partial void CoTaskMemFree(IntPtr pv);
/// <summary>
/// Opens <paramref name="folder"/> in Explorer with all of <paramref name="files"/> selected,
/// reusing a window already open at that folder. Falls back to <c>explorer.exe /select,</c>
/// (single item) and then to just opening the folder if the shell COM call fails.
/// </summary>
static void RevealInExplorer(string folder, IReadOnlyList<string> files)
{
IntPtr folderPidl = IntPtr.Zero;
var itemPidlAllocs = new List<IntPtr>();
var relativePidls = new List<IntPtr>();
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<string> 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.
}
}
}
}

133
ILSpy/Util/ShellHelper.cs

@ -17,29 +17,92 @@ @@ -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
{
/// <summary>
/// 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.
/// </summary>
public static class ShellHelper
public static partial class ShellHelper
{
/// <summary>Opens <paramref name="path"/> (a directory) in the OS file manager.</summary>
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.
}
}
/// <summary>
/// Reveals <paramref name="path"/> (a file) in the OS file manager, selecting it where the
/// platform supports it (Windows <c>/select,</c>, macOS <c>-R</c>). On Linux there is no
/// stable cross-distro "select file" hook, so the parent directory is opened instead.
/// platform supports it. See <see cref="RevealFiles"/> for the multi-file behaviour.
/// </summary>
public static void RevealFile(string path) => RevealFiles(new[] { path });
/// <summary>
/// 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 <c>open -R</c> reveals and selects; on Linux there is no
/// portable "select item" hook, so each distinct parent folder is opened once.
/// </summary>
public static void RevealFile(string path) => Launch(path, selectItem: true);
public static void RevealFiles(IEnumerable<string> 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);
}
}
/// <summary>Opens <paramref name="path"/> with its default application (image viewer,
/// browser, ...).</summary>
@ -59,32 +122,40 @@ namespace ICSharpCode.ILSpy.Util @@ -59,32 +122,40 @@ namespace ICSharpCode.ILSpy.Util
}
}
static void Launch(string path, bool selectItem)
/// <summary>
/// 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.
/// </summary>
internal static IReadOnlyList<(string Folder, IReadOnlyList<string> Files)> GroupByFolder(IEnumerable<string?>? paths)
{
try
if (paths is null)
return Array.Empty<(string, IReadOnlyList<string>)>();
var groups = new List<(string Folder, List<string> Files)>();
var folderIndex = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(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<string>()));
}
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<string>)g.Files)).ToList();
}
static string Quote(string value) => "\"" + value + "\"";
}
}

Loading…
Cancel
Save