Add Open in Notepad++ and static Windows shell verbs without embedding IContextMenu on right-click.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 14:24:00 +02:00
parent e2916aef9c
commit 66ea25993f
16 changed files with 1290 additions and 3 deletions

View File

@@ -34,6 +34,9 @@
<Button Content="Open in Cursor" MinWidth="120" Height="32"
Command="{Binding OpenSelectedInCursorCommand}"
IsEnabled="{Binding CanOpenInCursor}" Margin="0,0,8,8"/>
<Button Content="Open in Notepad++" MinWidth="140" Height="32"
Command="{Binding OpenSelectedInNotepadPlusPlusCommand}"
IsEnabled="{Binding CanOpenInCursor}" Margin="0,0,8,8"/>
<Button Content="Stage" MinWidth="72" Height="32" Click="OnStage"
IsEnabled="{Binding CanStage}" Margin="0,0,8,8"/>
<Button Content="Unstage" MinWidth="72" Height="32" Click="OnUnstage"
@@ -58,6 +61,8 @@
<MenuItem Header="View diff" Click="OnDiff" IsEnabled="{Binding CanDiff}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenSelectedInCursorCommand}"
IsEnabled="{Binding CanOpenInCursor}"/>
<MenuItem Header="Open in Notepad++" Command="{Binding OpenSelectedInNotepadPlusPlusCommand}"
IsEnabled="{Binding CanOpenInCursor}"/>
<Separator/>
<MenuItem Header="Stage" Click="OnStage" IsEnabled="{Binding CanStage}"/>
<MenuItem Header="Unstage" Click="OnUnstage" IsEnabled="{Binding CanUnstage}"/>

View File

@@ -26,7 +26,7 @@
<Window.Resources>
<ContextMenu x:Key="FolderListContextMenu" x:Shared="false">
<MenuItem Header="Open" Click="OnCtxOpen"/>
<Separator/>
<Separator Tag="ShellVerbAnchor" Visibility="Collapsed"/>
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
@@ -74,6 +74,8 @@
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Open in Notepad++" Command="{Binding OpenInNotepadPlusPlusCommand}"
Visibility="{Binding ShowOpenInNotepadPlusPlus, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to Favorites" Click="OnAddFavorite"
Visibility="{Binding ShowAddFavorite, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Favorites" Click="OnRemoveFavorite"
@@ -191,6 +193,8 @@
IsEnabled="{Binding ShowOpenTerminal}"/>
<MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}"
IsEnabled="{Binding ShowOpenInCursor}"/>
<MenuItem Header="Open in _Notepad++" Command="{Binding OpenInNotepadPlusPlusCommand}"
IsEnabled="{Binding ShowOpenInNotepadPlusPlus}"/>
</MenuItem>
<MenuItem Header="_Recycle Bin">
<MenuItem Header="_Open Recycle Bin" Click="OnOpenRecycleBin"/>

View File

@@ -8,6 +8,7 @@ using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
@@ -651,6 +652,15 @@ public partial class MainWindow : Window
if (sender is FrameworkElement { ContextMenu: { } menu })
{
menu.DataContext = DataContext;
try
{
FillShellContextVerbs(menu);
}
catch (Exception)
{
// Shell extras must not take down the Workbench menu.
}
_ = FillRunProfileMenuAsync(menu);
}
}
@@ -1364,6 +1374,66 @@ public partial class MainWindow : Window
dlg.ShowDialog();
}
private void FillShellContextVerbs(ContextMenu menu)
{
const string anchor = "ShellVerbAnchor";
const string tagPrefix = "shell:";
for (var i = menu.Items.Count - 1; i >= 0; i--)
{
if (menu.Items[i] is FrameworkElement { Tag: string tag }
&& tag.StartsWith(tagPrefix, StringComparison.Ordinal))
{
menu.Items.RemoveAt(i);
}
}
var anchorItem = menu.Items.OfType<Separator>().FirstOrDefault(item => Equals(item.Tag, anchor));
if (anchorItem is null)
{
return;
}
var verbs = Vm.ListShellContextVerbs();
anchorItem.Visibility = verbs.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
if (verbs.Count == 0)
{
return;
}
var index = menu.Items.IndexOf(anchorItem) + 1;
foreach (var verb in verbs)
{
menu.Items.Insert(index++, BuildShellMenuItem(verb));
}
}
private MenuItem BuildShellMenuItem(ShellContextVerb verb)
{
var item = new MenuItem { Header = verb.Label, Tag = "shell:" + verb.Id };
if (verb.Children is { Count: > 0 })
{
foreach (var child in verb.Children)
{
item.Items.Add(BuildShellMenuItem(child));
}
return item;
}
item.Click += OnShellContextVerb;
return item;
}
private void OnShellContextVerb(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { Tag: string tag } || !tag.StartsWith("shell:", StringComparison.Ordinal))
{
return;
}
Vm.InvokeShellContextVerb(tag["shell:".Length..]);
}
private async Task FillRunProfileMenuAsync(ContextMenu menu)
{
var host = menu.Items.OfType<MenuItem>().FirstOrDefault(i => Equals(i.Tag, "RunProfileMenu"));

View File

@@ -14,4 +14,5 @@ public interface IWorkspaceLauncher
{
void OpenTerminal(string directory);
bool TryOpenInCursor(string path);
bool TryOpenInNotepadPlusPlus(IReadOnlyList<string> paths);
}

View File

@@ -0,0 +1,42 @@
namespace Explorer.Application;
public static class NotepadPlusPlusLocator
{
public const string MissingHint = "Notepad++ is not installed.";
public static string? Find(Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
"Notepad++",
"notepad++.exe");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Notepad++",
"notepad++.exe");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs",
"Notepad++",
"notepad++.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "notepad++.exe");
}
}
}

View File

@@ -0,0 +1,203 @@
namespace Explorer.Application;
public static class ShellContextVerbFilter
{
private static readonly HashSet<string> HiddenVerbs = new(StringComparer.OrdinalIgnoreCase)
{
"open",
"opennewprocess",
"opennewwindow",
"explore",
"find",
"print",
"printto",
"cut",
"copy",
"paste",
"pastelink",
"delete",
"rename",
"link",
"properties",
"pintohome",
"unpinfromhome",
"windows.pin",
"windows.share",
"share",
"copypath",
"copyaspath",
"modernshare"
};
private static readonly HashSet<string> HiddenLabels = new(StringComparer.OrdinalIgnoreCase)
{
"Open",
"Cut",
"Copy",
"Paste",
"Delete",
"Rename",
"Properties",
"Open in Cursor",
"Open in Notepad++",
"Edit with Notepad++",
"Open terminal here"
};
public static string CanonicalLabel(string header)
=> header.Replace("&", "", StringComparison.Ordinal).Trim();
public static bool ShouldInclude(string? verb, string label)
{
var text = CanonicalLabel(label);
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
if (!string.IsNullOrWhiteSpace(verb) && HiddenVerbs.Contains(verb.Trim('\0')))
{
return false;
}
return !HiddenLabels.Contains(text);
}
public static IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> Prune(
IEnumerable<Explorer.Domain.Abstractions.ShellContextVerb> items,
int maxTopLevel = 30)
{
var kept = new List<Explorer.Domain.Abstractions.ShellContextVerb>();
foreach (var item in items)
{
if (kept.Count >= maxTopLevel)
{
break;
}
if (item.Children is not null)
{
var children = Prune(item.Children, 40);
if (children.Count == 0)
{
continue;
}
kept.Add(item with { Children = children });
continue;
}
if (!ShouldInclude(VerbFromId(item.Id), item.Label))
{
continue;
}
kept.Add(item);
}
return kept;
}
public static IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> MergeByLabel(
IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> primary,
IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> extra)
{
var merged = new List<Explorer.Domain.Abstractions.ShellContextVerb>(primary);
var seen = new HashSet<string>(
primary.Select(item => CanonicalLabel(item.Label)),
StringComparer.OrdinalIgnoreCase);
foreach (var item in extra)
{
if (seen.Add(CanonicalLabel(item.Label)))
{
merged.Add(item);
}
}
return merged;
}
public static string? VerbFromId(string id)
{
const string prefix = "verb:";
return id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? id[prefix.Length..]
: null;
}
}
public static class ShellVerbCommand
{
public static bool TrySplit(string command, out string executable, out string arguments)
{
executable = "";
arguments = "";
var text = command.Trim();
if (text.Length == 0)
{
return false;
}
if (text.StartsWith('"'))
{
var close = text.IndexOf('"', 1);
if (close <= 1)
{
return false;
}
executable = text[1..close];
arguments = text[(close + 1)..].Trim();
return executable.Length > 0;
}
var space = text.IndexOf(' ');
if (space < 0)
{
executable = text;
return true;
}
executable = text[..space];
arguments = text[(space + 1)..].Trim();
return executable.Length > 0;
}
public static IReadOnlyList<string> ExpandInvocations(string arguments, IReadOnlyList<string> paths)
{
if (paths.Count == 0)
{
return [];
}
var quoted = paths.Select(Quote).ToList();
var all = string.Join(' ', quoted);
var first = quoted[0];
if (arguments.Contains("%*", StringComparison.OrdinalIgnoreCase))
{
return [ReplaceTokens(arguments, first, all)];
}
if (paths.Count > 1
&& (arguments.Contains("%1", StringComparison.OrdinalIgnoreCase)
|| arguments.Contains("%L", StringComparison.OrdinalIgnoreCase)
|| arguments.Contains("%V", StringComparison.OrdinalIgnoreCase)))
{
return paths.Select(path => ReplaceTokens(arguments, Quote(path), Quote(path))).ToList();
}
return [ReplaceTokens(arguments, first, all)];
}
private static string ReplaceTokens(string arguments, string first, string all)
=> arguments
.Replace("%1", first, StringComparison.OrdinalIgnoreCase)
.Replace("%L", first, StringComparison.OrdinalIgnoreCase)
.Replace("%V", first, StringComparison.OrdinalIgnoreCase)
.Replace("%*", all, StringComparison.OrdinalIgnoreCase);
public static string Quote(string path)
=> path.Contains(' ', StringComparison.Ordinal) || path.Contains('\t', StringComparison.Ordinal)
? "\"" + path.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""
: path;
}

View File

@@ -94,6 +94,18 @@ public static class UsnReasons
public const int Close = unchecked((int)0x80000000);
}
public sealed record ShellContextVerb(
string Id,
string Label,
IReadOnlyList<ShellContextVerb>? Children = null);
public interface IShellContextMenu
{
IReadOnlyList<ShellContextVerb> Query(IReadOnlyList<string> paths);
bool TryInvoke(string id, out string? error);
bool TryShowFullMenu(IReadOnlyList<string> paths, int screenX, int screenY, nint ownerHwnd, out string? error);
}
public interface IShellFileOperations
{
void Open(string path);

View File

@@ -47,6 +47,7 @@ public static class ExplorerHostClientServices
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IShellContextMenu, WindowsShellContextMenu>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();

View File

@@ -158,6 +158,39 @@ public sealed partial class GitChangesViewModel : ObservableObject
}
}
[RelayCommand]
public async Task OpenSelectedInNotepadPlusPlusAsync()
{
if (Selected is null || string.IsNullOrWhiteSpace(RepoRoot))
{
return;
}
var full = Selected.ToFullPath(RepoRoot);
if (Selected.IsDeleted)
{
Status = "This path is deleted in the working tree.";
return;
}
if (await _hydration.WouldHydrateOnReadAsync(full).ConfigureAwait(true))
{
Status = "This file is online-only. Opening it would download it.";
return;
}
if (!File.Exists(full) && !Directory.Exists(full))
{
Status = "This path is not on disk.";
return;
}
if (!_workspace.TryOpenInNotepadPlusPlus([full]))
{
Status = NotepadPlusPlusLocator.MissingHint;
}
}
public GitCommitViewModel? CreateCommitViewModel()
{
if (string.IsNullOrWhiteSpace(RepoRoot) || Changes.Count == 0 || Operation != GitOperationKind.None)

View File

@@ -37,6 +37,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IThumbnailService? _thumbnails;
private readonly IHostConnection? _host;
private readonly IBackgroundMaintenance? _maintenance;
private readonly IShellContextMenu? _shellMenu;
private bool _hostStopped;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -62,6 +63,7 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal;
[ObservableProperty] private bool _showOpenInCursor;
[ObservableProperty] private bool _showOpenInNotepadPlusPlus;
[ObservableProperty] private bool _showGitActions;
[ObservableProperty] private bool _showAddFavorite;
[ObservableProperty] private bool _showRemoveFavorite;
@@ -98,7 +100,8 @@ public sealed partial class MainViewModel : ObservableObject
IKnownUserFolderCatalog? knownFolders = null,
IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null,
IBackgroundMaintenance? maintenance = null)
IBackgroundMaintenance? maintenance = null,
IShellContextMenu? shellMenu = null)
{
_browse = browse;
_ops = ops;
@@ -123,6 +126,7 @@ public sealed partial class MainViewModel : ObservableObject
_thumbnails = thumbnails;
_host = hostConnection;
_maintenance = maintenance;
_shellMenu = shellMenu;
if (_host is not null)
{
_host.StatusChanged += (_, status) =>
@@ -731,6 +735,35 @@ public sealed partial class MainViewModel : ObservableObject
public IReadOnlyList<string> SelectedRealPaths()
=> RealSelected().Select(i => i.FullPath).ToList();
public IReadOnlyList<ShellContextVerb> ListShellContextVerbs()
=> _shellMenu is null ? [] : _shellMenu.Query(SelectedRealPaths());
public void InvokeShellContextVerb(string id)
{
if (_shellMenu is null)
{
return;
}
if (!_shellMenu.TryInvoke(id, out var error))
{
Footer = string.IsNullOrWhiteSpace(error) ? "Could not run that command." : error;
}
}
public void ShowFullShellMenu(nint hwnd, int screenX, int screenY)
{
if (_shellMenu is null)
{
return;
}
if (!_shellMenu.TryShowFullMenu(SelectedRealPaths(), screenX, screenY, hwnd, out var error))
{
Footer = string.IsNullOrWhiteSpace(error) ? "Could not open the Windows menu." : error;
}
}
[RelayCommand]
public void OpenTerminal()
{
@@ -760,6 +793,28 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[RelayCommand]
public void OpenInNotepadPlusPlus()
{
var paths = SelectedRealPaths();
if (paths.Count == 0)
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a file to open in Notepad++.";
return;
}
paths = [directory];
}
if (!_workspace.TryOpenInNotepadPlusPlus(paths))
{
Footer = NotepadPlusPlusLocator.MissingHint;
}
}
[RelayCommand]
public async Task UndoRenameBatchAsync()
{
@@ -831,6 +886,7 @@ public sealed partial class MainViewModel : ObservableObject
var target = WorkspaceDirectory();
ShowOpenTerminal = target is not null;
ShowOpenInCursor = target is not null;
ShowOpenInNotepadPlusPlus = SelectedRealPaths().Count > 0 || target is not null;
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
var favoritePaths = FavoriteCandidates();
var pinned = _preferences.Load().FavoriteFolders;

View File

@@ -0,0 +1,702 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
namespace Explorer.Windows;
public sealed class WindowsShellContextMenu : IShellContextMenu
{
private const uint IdCmdFirst = 1;
private const uint IdCmdLast = 0x7FFF;
private const uint CmfNormal = 0;
private const uint CmfExplore = 4;
private const uint CmfCanRename = 0x10;
private const uint CmfExtendedVerbs = 0x100;
private const uint CmfItemMenu = 0x80;
private const uint TpmLeftAlign = 0x0000;
private const uint TpmRightButton = 0x0002;
private const uint TpmReturnCmd = 0x0100;
private const uint WmNull = 0x0000;
private const uint WmInitMenuPopup = 0x0117;
private const uint WmDrawItem = 0x002B;
private const uint WmMeasureItem = 0x002C;
private const uint WmMenuChar = 0x0120;
private const uint WmLButtonDown = 0x0201;
private const uint WmLButtonUp = 0x0202;
private const uint WmRButtonDown = 0x0204;
private const uint WmRButtonUp = 0x0205;
private const uint WsPopup = 0x80000000;
private const uint WsExToolwindow = 0x00000080;
private const uint WsExNoActivate = 0x08000000;
private const uint PmRemove = 0x0001;
private const int GwlpWndProc = -4;
private const int SwShowNormal = 1;
private const int SwShowNoActivate = 8;
private const int VkLButton = 0x01;
private const int VkRButton = 0x02;
private static readonly Guid ShellItemId = new("43826d1e-e718-42ee-bc55-a1e261c37bfe");
private static readonly Guid ContextMenuId = new("000214e4-0000-0000-c000-000000000046");
private static readonly Guid SfuiObject = new("3981e224-f559-11d3-8e3a-00c04f6837d5");
private readonly ILogger<WindowsShellContextMenu> _logger;
private Dictionary<string, string>? _staticCommands;
private IReadOnlyList<string> _paths = [];
private nint _oldWndProc;
private WndProc? _subclassProc;
private IContextMenu2? _menu2;
private IContextMenu3? _menu3;
public WindowsShellContextMenu(ILogger<WindowsShellContextMenu> logger) => _logger = logger;
public IReadOnlyList<ShellContextVerb> Query(IReadOnlyList<string> paths)
{
_paths = Normalize(paths);
if (_paths.Count == 0)
{
_staticCommands = null;
return [];
}
try
{
return ShellContextVerbFilter.Prune(QueryRegistryVerbs(_paths));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Registry shell verbs failed");
_staticCommands = null;
return [];
}
}
public bool TryInvoke(string id, out string? error)
{
error = null;
try
{
if (id.StartsWith("static:", StringComparison.Ordinal)
&& _staticCommands is not null
&& _staticCommands.TryGetValue(id, out var command)
&& ShellVerbCommand.TrySplit(command, out var exe, out var args))
{
foreach (var invocation in ShellVerbCommand.ExpandInvocations(args, _paths))
{
Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = invocation,
UseShellExecute = false
});
}
return true;
}
}
catch (Exception ex)
{
error = ex.Message;
_logger.LogDebug(ex, "Shell verb invoke failed {Id}", id);
return false;
}
error = "Command is no longer available.";
return false;
}
public bool TryShowFullMenu(IReadOnlyList<string> paths, int screenX, int screenY, nint ownerHwnd, out string? error)
{
error = null;
var normalized = Normalize(paths);
if (normalized.Count == 0)
{
error = "Select a file first.";
return false;
}
nint hMenu = 0;
nint host = 0;
IContextMenu? menu = null;
try
{
if (!TryCreateShellMenu(normalized, out menu) || menu is null)
{
error = "Windows could not build the full menu.";
return false;
}
hMenu = CreatePopupMenu();
var hr = menu.QueryContextMenu(
hMenu,
0,
IdCmdFirst,
IdCmdLast,
CmfNormal | CmfExplore | CmfCanRename | CmfItemMenu | CmfExtendedVerbs);
if (hr < 0)
{
error = "Windows could not fill the full menu.";
return false;
}
DrainPendingMouseClicks();
WaitForMouseButtonsUp();
// Message-only windows cannot host a visible TrackPopupMenu. Own a tiny popup
// from the Workbench HWND so IContextMenu2/3 messages stay off the WPF wndproc.
host = CreateWindowEx(
WsExToolwindow | WsExNoActivate,
"Static",
"",
WsPopup,
screenX,
screenY,
1,
1,
ownerHwnd,
0,
GetModuleHandle(null),
0);
var hwnd = host != 0 ? host : ownerHwnd;
if (hwnd == 0)
{
error = "Windows could not open the full menu.";
return false;
}
_menu2 = QueryInterface<IContextMenu2>(menu);
_menu3 = QueryInterface<IContextMenu3>(menu);
if (host != 0)
{
ShowWindow(host, SwShowNoActivate);
Subclass(host);
}
SetForegroundWindow(ownerHwnd != 0 ? ownerHwnd : hwnd);
var cmd = TrackPopupMenuEx(
hMenu,
TpmLeftAlign | TpmRightButton | TpmReturnCmd,
screenX,
screenY,
hwnd,
0);
PostMessage(hwnd, WmNull, 0, 0);
if (cmd < IdCmdFirst)
{
return true;
}
return InvokeOffset(menu, cmd - IdCmdFirst, ownerHwnd != 0 ? ownerHwnd : hwnd, out error);
}
catch (Exception ex)
{
error = ex.Message;
_logger.LogDebug(ex, "Full shell menu failed");
return false;
}
finally
{
if (host != 0)
{
Unsubclass(host);
DestroyWindow(host);
}
_menu2 = null;
_menu3 = null;
if (hMenu != 0)
{
DestroyMenu(hMenu);
}
if (menu is not null)
{
Marshal.ReleaseComObject(menu);
}
}
}
private static IReadOnlyList<string> Normalize(IReadOnlyList<string> paths)
=> paths
.Where(path => !string.IsNullOrWhiteSpace(path) && !LocationRoots.IsVirtual(path))
.Select(PathRules.FromExtended)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(32)
.ToList();
private bool InvokeOffset(IContextMenu menu, uint offset, nint hwnd, out string? error)
{
error = null;
var info = new Cminvokecommandinfo
{
cbSize = Marshal.SizeOf<Cminvokecommandinfo>(),
fMask = 0,
hwnd = hwnd,
lpVerb = (nint)offset,
lpParameters = null,
lpDirectory = null,
nShow = SwShowNormal
};
var hr = menu.InvokeCommand(ref info);
if (hr < 0)
{
error = "The Windows command failed.";
_logger.LogDebug("IContextMenu.InvokeCommand offset={Offset} hr={Hr}", offset, hr);
return false;
}
return true;
}
private static bool TryCreateShellMenu(IReadOnlyList<string> paths, out IContextMenu? menu)
=> (paths.Count == 1 && TryCreateItemMenu(paths[0], out menu))
|| TryCreateItemArrayMenu(paths, out menu);
private static bool TryCreateItemMenu(string path, out IContextMenu? menu)
{
menu = null;
var iidItem = ShellItemId;
if (SHCreateItemFromParsingName(path, 0, ref iidItem, out var item) != 0 || item is null)
{
return false;
}
try
{
return TryBindContextMenu(item, out menu);
}
finally
{
Marshal.ReleaseComObject(item);
}
}
private static bool TryCreateItemArrayMenu(IReadOnlyList<string> paths, out IContextMenu? menu)
{
menu = null;
var pidls = new List<nint>();
try
{
foreach (var path in paths)
{
if (SHParseDisplayName(path, 0, out var pidl, 0, out _) != 0 || pidl == 0)
{
return false;
}
pidls.Add(pidl);
}
if (SHCreateShellItemArrayFromIDLists((uint)pidls.Count, pidls.ToArray(), out var array) != 0
|| array is null)
{
return false;
}
try
{
return TryBindContextMenu(array, out menu);
}
finally
{
Marshal.ReleaseComObject(array);
}
}
catch
{
return false;
}
finally
{
foreach (var pidl in pidls)
{
ILFree(pidl);
}
}
}
private static bool TryBindContextMenu(IShellItem item, out IContextMenu? menu)
{
var bhid = SfuiObject;
var iid = ContextMenuId;
return TryWrapContextMenu(item.BindToHandler(0, ref bhid, ref iid, out var unk), unk, out menu);
}
private static bool TryBindContextMenu(IShellItemArray array, out IContextMenu? menu)
{
var bhid = SfuiObject;
var iid = ContextMenuId;
return TryWrapContextMenu(array.BindToHandler(0, ref bhid, ref iid, out var unk), unk, out menu);
}
private static bool TryWrapContextMenu(int hr, nint unk, out IContextMenu? menu)
{
menu = null;
if (hr != 0 || unk == 0)
{
return false;
}
try
{
menu = (IContextMenu)Marshal.GetObjectForIUnknown(unk);
return menu is not null;
}
finally
{
Marshal.Release(unk);
}
}
private static void DrainPendingMouseClicks()
{
while (PeekMessage(out _, 0, WmLButtonDown, WmLButtonUp, PmRemove))
{
}
while (PeekMessage(out _, 0, WmRButtonDown, WmRButtonUp, PmRemove))
{
}
}
private static void WaitForMouseButtonsUp()
{
var start = Environment.TickCount64;
while (Environment.TickCount64 - start < 250
&& ((GetAsyncKeyState(VkLButton) & 0x8000) != 0 || (GetAsyncKeyState(VkRButton) & 0x8000) != 0))
{
DrainPendingMouseClicks();
Thread.Sleep(10);
}
}
private IReadOnlyList<ShellContextVerb> QueryRegistryVerbs(IReadOnlyList<string> paths)
{
_staticCommands = new Dictionary<string, string>(StringComparer.Ordinal);
var allFiles = paths.All(File.Exists);
var allDirs = paths.All(Directory.Exists);
var keys = new List<string>();
if (allFiles)
{
keys.Add(@"*\shell");
var exts = paths.Select(path => Path.GetExtension(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (exts.Count == 1 && !string.IsNullOrEmpty(exts[0]))
{
keys.AddRange(ProgIdShellKeys(exts[0]));
}
}
else if (allDirs)
{
keys.Add(@"Directory\shell");
keys.Add(@"Folder\shell");
}
var items = new List<ShellContextVerb>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var key in keys)
{
foreach (var hive in new[] { Registry.ClassesRoot, Registry.CurrentUser })
{
var path = hive == Registry.CurrentUser ? @"Software\Classes\" + key : key;
using var shell = hive.OpenSubKey(path);
if (shell is null)
{
continue;
}
foreach (var name in shell.GetSubKeyNames())
{
using var verbKey = shell.OpenSubKey(name);
if (verbKey is null || verbKey.GetValue("LegacyDisable") is not null
|| verbKey.GetValue("ProgrammaticAccessOnly") is not null)
{
continue;
}
using var commandKey = verbKey.OpenSubKey("command");
var command = commandKey?.GetValue(null) as string;
if (string.IsNullOrWhiteSpace(command) || commandKey?.GetValue("DelegateExecute") is not null)
{
continue;
}
var label = (verbKey.GetValue("MUIVerb") as string) ?? (verbKey.GetValue(null) as string) ?? name;
if (label.StartsWith('@'))
{
label = name;
}
var canonical = ShellContextVerbFilter.CanonicalLabel(label);
if (!seen.Add(canonical) || !ShellContextVerbFilter.ShouldInclude(name, label))
{
continue;
}
var id = "static:" + items.Count;
_staticCommands[id] = command;
items.Add(new ShellContextVerb(id, label));
}
}
}
return items;
}
private static IEnumerable<string> ProgIdShellKeys(string extension)
{
yield return @"SystemFileAssociations\" + extension + @"\shell";
using var extKey = Registry.ClassesRoot.OpenSubKey(extension);
var progId = extKey?.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(progId))
{
yield return progId + @"\shell";
}
}
private void Subclass(nint hwnd)
{
_subclassProc = OnSubclass;
_oldWndProc = SetWindowLongPtr(hwnd, GwlpWndProc, Marshal.GetFunctionPointerForDelegate(_subclassProc));
}
private void Unsubclass(nint hwnd)
{
if (hwnd == 0 || _oldWndProc == 0)
{
return;
}
SetWindowLongPtr(hwnd, GwlpWndProc, _oldWndProc);
_oldWndProc = 0;
_subclassProc = null;
}
private nint OnSubclass(nint hWnd, uint msg, nint wParam, nint lParam)
{
if (_menu3 is not null
&& msg is WmInitMenuPopup or WmDrawItem or WmMeasureItem or WmMenuChar
&& _menu3.HandleMenuMsg2(msg, wParam, lParam, out var result) == 0)
{
return result;
}
if (_menu2 is not null
&& msg is WmInitMenuPopup or WmDrawItem or WmMeasureItem or WmMenuChar)
{
_menu2.HandleMenuMsg(msg, wParam, lParam);
return 0;
}
return CallWindowProc(_oldWndProc, hWnd, msg, wParam, lParam);
}
private static T? QueryInterface<T>(object com) where T : class
{
var unk = Marshal.GetIUnknownForObject(com);
try
{
var iid = typeof(T).GUID;
if (Marshal.QueryInterface(unk, in iid, out var ptr) != 0 || ptr == 0)
{
return null;
}
try
{
return (T)Marshal.GetObjectForIUnknown(ptr);
}
finally
{
Marshal.Release(ptr);
}
}
finally
{
Marshal.Release(unk);
}
}
private delegate nint WndProc(nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(nint hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(nint hWnd, int nCmdShow);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll")]
private static extern short GetAsyncKeyState(int vKey);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PeekMessageW")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(out NativeMsg msg, nint hWnd, uint msgMin, uint msgMax, uint remove);
[DllImport("user32.dll")]
private static extern uint TrackPopupMenuEx(nint hmenu, uint flags, int x, int y, nint hwnd, nint paramsEx);
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static extern nint SetWindowLongPtr64(nint hWnd, int nIndex, nint dwNewLong);
[DllImport("user32.dll", EntryPoint = "SetWindowLongW")]
private static extern int SetWindowLong32(nint hWnd, int nIndex, int dwNewLong);
private static nint SetWindowLongPtr(nint hWnd, int nIndex, nint value)
=> nint.Size == 8
? SetWindowLongPtr64(hWnd, nIndex, value)
: SetWindowLong32(hWnd, nIndex, (int)value);
[DllImport("user32.dll")]
private static extern nint CallWindowProc(nint prev, nint hWnd, uint msg, nint wParam, nint lParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateWindowExW")]
private static extern nint CreateWindowEx(
uint exStyle,
string className,
string windowName,
uint style,
int x,
int y,
int width,
int height,
nint parent,
nint menu,
nint instance,
nint param);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(nint hWnd);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandle(string? module);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHParseDisplayName(string name, nint bindCtx, out nint pidl, uint sfgaoIn, out uint sfgaoOut);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHCreateItemFromParsingName(
string path,
nint bindCtx,
[In] ref Guid riid,
[MarshalAs(UnmanagedType.Interface)] out IShellItem item);
[DllImport("shell32.dll")]
private static extern int SHCreateShellItemArrayFromIDLists(uint cidl, [In] nint[] pidls, out IShellItemArray array);
[DllImport("shell32.dll")]
private static extern void ILFree(nint pidl);
[DllImport("user32.dll")]
private static extern nint CreatePopupMenu();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyMenu(nint hMenu);
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")]
private interface IShellItem
{
[PreserveSig]
int BindToHandler(nint pbc, ref Guid bhid, ref Guid riid, out nint ppv);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("b63ea76d-1f85-456f-a19c-48159efa858b")]
private interface IShellItemArray
{
[PreserveSig]
int BindToHandler(nint pbc, ref Guid bhid, ref Guid riid, out nint ppv);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214e4-0000-0000-c000-000000000046")]
private interface IContextMenu
{
[PreserveSig]
int QueryContextMenu(nint hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig]
int InvokeCommand(ref Cminvokecommandinfo info);
[PreserveSig]
int GetCommandString(UIntPtr idCmd, uint uType, nint reserved, nint name, uint cchMax);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214f4-0000-0000-c000-000000000046")]
private interface IContextMenu2
{
[PreserveSig]
int QueryContextMenu(nint hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig]
int InvokeCommand(ref Cminvokecommandinfo info);
[PreserveSig]
int GetCommandString(UIntPtr idCmd, uint uType, nint reserved, nint name, uint cchMax);
[PreserveSig]
int HandleMenuMsg(uint uMsg, nint wParam, nint lParam);
}
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214fc-0000-0000-c000-000000000046")]
private interface IContextMenu3
{
[PreserveSig]
int QueryContextMenu(nint hmenu, uint indexMenu, uint idCmdFirst, uint idCmdLast, uint uFlags);
[PreserveSig]
int InvokeCommand(ref Cminvokecommandinfo info);
[PreserveSig]
int GetCommandString(UIntPtr idCmd, uint uType, nint reserved, nint name, uint cchMax);
[PreserveSig]
int HandleMenuMsg(uint uMsg, nint wParam, nint lParam);
[PreserveSig]
int HandleMenuMsg2(uint uMsg, nint wParam, nint lParam, out nint result);
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeMsg
{
public nint hwnd;
public uint message;
public nint wParam;
public nint lParam;
public uint time;
public int ptX;
public int ptY;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
private struct Cminvokecommandinfo
{
public int cbSize;
public int fMask;
public nint hwnd;
public nint lpVerb;
public string? lpParameters;
public string? lpDirectory;
public int nShow;
public int dwHotKey;
public nint hIcon;
}
}

View File

@@ -1,6 +1,7 @@
using System.Diagnostics;
using Explorer.Application;
using Explorer.Domain;
using Microsoft.Win32;
namespace Explorer.Windows;
@@ -39,6 +40,63 @@ public sealed class WindowsWorkspaceLauncher : IWorkspaceLauncher
return TryStart("cursor", Quote(target));
}
public bool TryOpenInNotepadPlusPlus(IReadOnlyList<string> paths)
{
var targets = paths
.Select(PathRules.FromExtended)
.Where(path => File.Exists(path) || Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(32)
.ToList();
if (targets.Count == 0)
{
return false;
}
var exe = NotepadPlusPlusLocator.Find() ?? FindNotepadPlusPlusFromRegistry();
var args = string.Join(" ", targets.Select(Quote));
if (exe is not null)
{
return TryStart(exe, args);
}
return TryStart("notepad++", args);
}
private static string? FindNotepadPlusPlusFromRegistry()
{
foreach (var path in new[]
{
@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\notepad++.exe",
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\notepad++.exe"
})
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(path);
if (key?.GetValue(null) is string exe && File.Exists(exe))
{
return exe;
}
if (key?.GetValue("Path") is string directory)
{
var nested = Path.Combine(directory, "notepad++.exe");
if (File.Exists(nested))
{
return nested;
}
}
}
catch
{
// Missing or unreadable App Paths is not fatal.
}
}
return null;
}
private static bool TryStart(string fileName, string arguments)
{
try