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

@@ -903,4 +903,5 @@ Deviations from the design above, with reasons:
13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read. 13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read.
14. **Index freshness** — folder sizes in the listing come from `aggregate_size`. Watcher reconcile stays one directory deep. Opening a folder enqueues a 1-level reconcile of that path, then `FolderDisplayRefresh` probes up to 8 largest/visible child dirs (child count). Mismatches get `EnqueueVerify` for that child only and are cancelled when the browse generation changes. Idle maintenance still verifies each local indexed source (deep walk, cap 48). USN directory deletes also tombstone the path prefix. 14. **Index freshness** — folder sizes in the listing come from `aggregate_size`. Watcher reconcile stays one directory deep. Opening a folder enqueues a 1-level reconcile of that path, then `FolderDisplayRefresh` probes up to 8 largest/visible child dirs (child count). Mismatches get `EnqueueVerify` for that child only and are cancelled when the browse generation changes. Idle maintenance still verifies each local indexed source (deep walk, cap 48). USN directory deletes also tombstone the path prefix.
15. **Browse names first** — live folder listing starts without waiting for source lookup, archive index, `MarkReachable`, or child-index overlay. The first name is published immediately (`BrowseHydration.FirstPublish`); folder sizes, cloud state, and Git badges arrive as later `BrowseDelta` updates. Archive paths still resolve through the index before live enumeration. 15. **Browse names first** — live folder listing starts without waiting for source lookup, archive index, `MarkReachable`, or child-index overlay. The first name is published immediately (`BrowseHydration.FirstPublish`); folder sizes, cloud state, and Git badges arrive as later `BrowseDelta` updates. Archive paths still resolve through the index before live enumeration.
16. **Shell context verbs** — the compact item menu only reads static registry verbs (no `IContextMenu` on right-click; that AVd via `IShellFolder.GetUIObjectOf` / `MENUITEMINFO` string marshaling). **Open in Notepad++** is a first-class Workbench command (same pattern as **Open in Cursor**), not a shell-extension row. `IContextMenu` / `TrackPopupMenu` is not used on the compact menu.

View File

@@ -120,6 +120,8 @@ The open folder is always the live filesystem when the location is online. Index
Large folders appear as soon as names are known — the previous folder stays on screen until the first new name arrives. Size, date, and type from the directory listing show with the row. Cloud status, indexed folder totals, and Git badges fill in shortly after — blank cells mean that extra metadata has not arrived yet, not that the file is empty. Opening another folder cancels leftover work from the previous one. The status bar shows how many items are in the active folder and their known size; with a selection it switches to how many are selected and the size of that selection. Folder sizes appear in the total once the index has them. Large folders appear as soon as names are known — the previous folder stays on screen until the first new name arrives. Size, date, and type from the directory listing show with the row. Cloud status, indexed folder totals, and Git badges fill in shortly after — blank cells mean that extra metadata has not arrived yet, not that the file is empty. Opening another folder cancels leftover work from the previous one. The status bar shows how many items are in the active folder and their known size; with a selection it switches to how many are selected and the size of that selection. Folder sizes appear in the total once the index has them.
Right-click a file or folder to get Workbench commands plus extra Windows items that fit in the compact menu (PDF24, 7-Zip, and similar). **Open in Notepad++** and **Open in Cursor** are Workbench entries next to **Open terminal here**. Open, Cut, Copy, Delete, and Rename stay Workbenchs own entries so they are not listed twice. Listing those items does not download online-only cloud files; running one of them is an explicit open and may hydrate.
**Preview** shows a wrapping thumbnail grid. Only the tiles on screen (plus a small prefetch) are decoded, on background threads. Other files keep a generic icon until you scroll to them. Online-only cloud files are never opened just to make a thumbnail. Returning to a folder reuses thumbnails that are still in memory. **Preview** shows a wrapping thumbnail grid. Only the tiles on screen (plus a small prefetch) are decoded, on background threads. Other files keep a generic icon until you scroll to them. Online-only cloud files are never opened just to make a thumbnail. Returning to a folder reuses thumbnails that are still in memory.
Sorting by name or type happens once the listing is in. Sorting by size waits until indexed folder totals are applied, so rows do not jump on every update. Clicking a column sorts immediately with whatever is already known. Sorting by name or type happens once the listing is in. Sorting by size waits until indexed folder totals are applied, so rows do not jump on every update. Clicking a column sorts immediately with whatever is already known.
@@ -316,7 +318,7 @@ Never auto-reorganizes. No MIME/content sniffing (that would hydrate cloud files
## Git ## Git
Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, and **Open in Cursor** when a folder is in a repository. Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, **Open in Cursor**, and **Open in Notepad++** when a folder is in a repository.
**View changes** lists staged, unstaged, untracked, and unmerged paths from `git status`. Double-click opens a unified **diff** (`git diff` / `git diff --cached`). **Open in Cursor** opens the file. Online-only cloud files are not opened or diffed (that would download them). Stage, unstage, and discard call the matching `git` commands. Discard asks first. **View changes** lists staged, unstaged, untracked, and unmerged paths from `git status`. Double-click opens a unified **diff** (`git diff` / `git diff --cached`). **Open in Cursor** opens the file. Online-only cloud files are not opened or diffed (that would download them). Stage, unstage, and discard call the matching `git` commands. Discard asks first.

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Threading; using System.Windows.Threading;
using Explorer.Domain; using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Presentation; using Explorer.Presentation;
using Explorer.Presentation.ViewModels; using Explorer.Presentation.ViewModels;
@@ -651,6 +652,15 @@ public partial class MainWindow : Window
if (sender is FrameworkElement { ContextMenu: { } menu }) if (sender is FrameworkElement { ContextMenu: { } menu })
{ {
menu.DataContext = DataContext; menu.DataContext = DataContext;
try
{
FillShellContextVerbs(menu);
}
catch (Exception)
{
// Shell extras must not take down the Workbench menu.
}
_ = FillRunProfileMenuAsync(menu); _ = FillRunProfileMenuAsync(menu);
} }
} }
@@ -1364,6 +1374,66 @@ public partial class MainWindow : Window
dlg.ShowDialog(); 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) private async Task FillRunProfileMenuAsync(ContextMenu menu)
{ {
var host = menu.Items.OfType<MenuItem>().FirstOrDefault(i => Equals(i.Tag, "RunProfileMenu")); 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); void OpenTerminal(string directory);
bool TryOpenInCursor(string path); 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 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 public interface IShellFileOperations
{ {
void Open(string path); void Open(string path);

View File

@@ -47,6 +47,7 @@ public static class ExplorerHostClientServices
services.AddSingleton<IVolumeService, WindowsVolumeService>(); services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>(); services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>(); services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IShellContextMenu, WindowsShellContextMenu>();
services.AddSingleton<IIndexStore>(sp => services.AddSingleton<IIndexStore>(sp =>
{ {
var env = sp.GetRequiredService<IAppEnvironment>(); 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() public GitCommitViewModel? CreateCommitViewModel()
{ {
if (string.IsNullOrWhiteSpace(RepoRoot) || Changes.Count == 0 || Operation != GitOperationKind.None) 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 IThumbnailService? _thumbnails;
private readonly IHostConnection? _host; private readonly IHostConnection? _host;
private readonly IBackgroundMaintenance? _maintenance; private readonly IBackgroundMaintenance? _maintenance;
private readonly IShellContextMenu? _shellMenu;
private bool _hostStopped; private bool _hostStopped;
private List<string> _clipboard = []; private List<string> _clipboard = [];
private bool _clipboardIsCut; private bool _clipboardIsCut;
@@ -62,6 +63,7 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showVerifyArchive; [ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal; [ObservableProperty] private bool _showOpenTerminal;
[ObservableProperty] private bool _showOpenInCursor; [ObservableProperty] private bool _showOpenInCursor;
[ObservableProperty] private bool _showOpenInNotepadPlusPlus;
[ObservableProperty] private bool _showGitActions; [ObservableProperty] private bool _showGitActions;
[ObservableProperty] private bool _showAddFavorite; [ObservableProperty] private bool _showAddFavorite;
[ObservableProperty] private bool _showRemoveFavorite; [ObservableProperty] private bool _showRemoveFavorite;
@@ -98,7 +100,8 @@ public sealed partial class MainViewModel : ObservableObject
IKnownUserFolderCatalog? knownFolders = null, IKnownUserFolderCatalog? knownFolders = null,
IThumbnailService? thumbnails = null, IThumbnailService? thumbnails = null,
IHostConnection? hostConnection = null, IHostConnection? hostConnection = null,
IBackgroundMaintenance? maintenance = null) IBackgroundMaintenance? maintenance = null,
IShellContextMenu? shellMenu = null)
{ {
_browse = browse; _browse = browse;
_ops = ops; _ops = ops;
@@ -123,6 +126,7 @@ public sealed partial class MainViewModel : ObservableObject
_thumbnails = thumbnails; _thumbnails = thumbnails;
_host = hostConnection; _host = hostConnection;
_maintenance = maintenance; _maintenance = maintenance;
_shellMenu = shellMenu;
if (_host is not null) if (_host is not null)
{ {
_host.StatusChanged += (_, status) => _host.StatusChanged += (_, status) =>
@@ -731,6 +735,35 @@ public sealed partial class MainViewModel : ObservableObject
public IReadOnlyList<string> SelectedRealPaths() public IReadOnlyList<string> SelectedRealPaths()
=> RealSelected().Select(i => i.FullPath).ToList(); => 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] [RelayCommand]
public void OpenTerminal() 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] [RelayCommand]
public async Task UndoRenameBatchAsync() public async Task UndoRenameBatchAsync()
{ {
@@ -831,6 +886,7 @@ public sealed partial class MainViewModel : ObservableObject
var target = WorkspaceDirectory(); var target = WorkspaceDirectory();
ShowOpenTerminal = target is not null; ShowOpenTerminal = target is not null;
ShowOpenInCursor = target is not null; ShowOpenInCursor = target is not null;
ShowOpenInNotepadPlusPlus = SelectedRealPaths().Count > 0 || target is not null;
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge); ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
var favoritePaths = FavoriteCandidates(); var favoritePaths = FavoriteCandidates();
var pinned = _preferences.Load().FavoriteFolders; 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 System.Diagnostics;
using Explorer.Application; using Explorer.Application;
using Explorer.Domain; using Explorer.Domain;
using Microsoft.Win32;
namespace Explorer.Windows; namespace Explorer.Windows;
@@ -39,6 +40,63 @@ public sealed class WindowsWorkspaceLauncher : IWorkspaceLauncher
return TryStart("cursor", Quote(target)); 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) private static bool TryStart(string fileName, string arguments)
{ {
try try

View File

@@ -0,0 +1,22 @@
using Explorer.Application;
namespace Explorer.Application.Tests;
public class NotepadPlusPlusLocatorTests
{
[Fact]
public void Finds_notepad_plus_plus_on_PATH()
{
var found = NotepadPlusPlusLocator.Find(
fileExists: p => p.Equals(@"D:\apps\notepad++.exe", StringComparison.OrdinalIgnoreCase),
pathVariable: @"C:\Windows;D:\apps");
Assert.Equal(@"D:\apps\notepad++.exe", found);
}
[Fact]
public void Returns_null_when_missing()
{
Assert.Null(NotepadPlusPlusLocator.Find(fileExists: _ => false, pathVariable: @"C:\none"));
Assert.Contains("Notepad++", NotepadPlusPlusLocator.MissingHint, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,75 @@
using Explorer.Application;
using Explorer.Domain.Abstractions;
namespace Explorer.Application.Tests;
public class ShellContextVerbFilterTests
{
[Fact]
public void Keeps_third_party_open_with_and_hides_workbench_duplicates()
{
Assert.True(ShellContextVerbFilter.ShouldInclude("Open with Notepad++", "Open with &Notepad++"));
Assert.True(ShellContextVerbFilter.ShouldInclude("edit", "Edit"));
Assert.True(ShellContextVerbFilter.ShouldInclude("openas", "Open with"));
Assert.False(ShellContextVerbFilter.ShouldInclude("open", "Open"));
Assert.False(ShellContextVerbFilter.ShouldInclude("cut", "Cu&t"));
Assert.False(ShellContextVerbFilter.ShouldInclude("properties", "P&roperties"));
Assert.False(ShellContextVerbFilter.ShouldInclude(null, "Open in Cursor"));
Assert.False(ShellContextVerbFilter.ShouldInclude(null, "Open in Notepad++"));
Assert.False(ShellContextVerbFilter.ShouldInclude(null, "Edit with Notepad++"));
Assert.Equal("Open with Notepad++", ShellContextVerbFilter.CanonicalLabel("Open with &Notepad++"));
}
[Fact]
public void Prune_drops_empty_submenus_and_builtin_rows()
{
var items = new[]
{
new ShellContextVerb("verb:open", "Open"),
new ShellContextVerb("verb:npp", "Open with &Notepad++"),
new ShellContextVerb("verb:zip", "7-Zip", [
new ShellContextVerb("verb:zip.add", "Add to archive…")
]),
new ShellContextVerb("verb:empty", "Empty", [])
};
var pruned = ShellContextVerbFilter.Prune(items);
Assert.Equal(2, pruned.Count);
Assert.Equal("Open with &Notepad++", pruned[0].Label);
Assert.Equal("7-Zip", pruned[1].Label);
Assert.Single(pruned[1].Children!);
}
[Fact]
public void MergeByLabel_adds_missing_third_party_verbs()
{
var merged = ShellContextVerbFilter.MergeByLabel(
[new ShellContextVerb("cmd:1", "PDF24")],
[new ShellContextVerb("static:0", "Open with &Notepad++"), new ShellContextVerb("cmd:2", "PDF24")]);
Assert.Equal(2, merged.Count);
Assert.Equal("PDF24", merged[0].Label);
Assert.Equal("Open with &Notepad++", merged[1].Label);
}
[Fact]
public void Command_split_and_expand_runs_once_per_file_for_percent_one()
{
Assert.True(ShellVerbCommand.TrySplit(
@"""C:\Program Files\Notepad++\notepad++.exe"" ""%1""",
out var exe,
out var args));
Assert.Equal(@"C:\Program Files\Notepad++\notepad++.exe", exe);
var invocations = ShellVerbCommand.ExpandInvocations(args, [@"C:\a.txt", @"C:\b c.txt"]);
Assert.Equal(2, invocations.Count);
Assert.Equal(@"""C:\a.txt""", invocations[0].Trim());
Assert.Contains(@"C:\b c.txt", invocations[1], StringComparison.Ordinal);
}
[Fact]
public void Percent_star_keeps_one_invocation()
{
var invocations = ShellVerbCommand.ExpandInvocations("%*", [@"C:\a.txt", @"C:\b.txt"]);
Assert.Single(invocations);
Assert.Contains(@"C:\a.txt", invocations[0], StringComparison.Ordinal);
Assert.Contains(@"C:\b.txt", invocations[0], StringComparison.Ordinal);
}
}