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:
@@ -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}"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -14,4 +14,5 @@ public interface IWorkspaceLauncher
|
||||
{
|
||||
void OpenTerminal(string directory);
|
||||
bool TryOpenInCursor(string path);
|
||||
bool TryOpenInNotepadPlusPlus(IReadOnlyList<string> paths);
|
||||
}
|
||||
|
||||
42
src/Explorer.Application/NotepadPlusPlusLocator.cs
Normal file
42
src/Explorer.Application/NotepadPlusPlusLocator.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
203
src/Explorer.Application/ShellContextVerbFilter.cs
Normal file
203
src/Explorer.Application/ShellContextVerbFilter.cs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
702
src/Explorer.Windows/WindowsShellContextMenu.cs
Normal file
702
src/Explorer.Windows/WindowsShellContextMenu.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user