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