Skip to content

Commit 5747e5e

Browse files
authored
CmdPal: Prevent crash on duplicate keybindings; simplify matching (#41714)
## Summary of the Pull Request Handles duplicate keybindings by using the first occurrence and ignoring the rest (in `ContextMenuViewModel.Keybindings` and `IContextMenuContext.Keybindings`). Replaces LINQ with direct iteration for clarity. Simplifies `CheckKeybinding` by removing redundant null checks and clarifying the key-to-binding matching logic, improving both readability and efficiency. Add a new method to `KeyChordHelpers.FormatForDebug` that formats KeyChord as string to help debugging. Makes `KeyChordHelpers` class a static class. <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #41712 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** all pass - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [x] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [x] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed Validated using a custom extension that has a duplicate item in the context menu.
1 parent 2a98211 commit 5747e5e

File tree

4 files changed

+122
-25
lines changed

4 files changed

+122
-25
lines changed

src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.ObjectModel;
66
using CommunityToolkit.Mvvm.ComponentModel;
77
using CommunityToolkit.Mvvm.Messaging;
8+
using ManagedCommon;
89
using Microsoft.CmdPal.Core.ViewModels.Messages;
910
using Microsoft.CommandPalette.Extensions;
1011
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -117,36 +118,46 @@ private static int ScoreContextCommand(string query, CommandContextItemViewModel
117118
/// Generates a mapping of key -> command item for this particular item's
118119
/// MoreCommands. (This won't include the primary Command, but it will
119120
/// include the secondary one). This map can be used to quickly check if a
120-
/// shortcut key was pressed
121+
/// shortcut key was pressed. In case there are duplicate keybindings, the first
122+
/// one is used and the rest are ignored.
121123
/// </summary>
122124
/// <returns>a dictionary of KeyChord -> Context commands, for all commands
123125
/// that have a shortcut key set.</returns>
124-
public Dictionary<KeyChord, CommandContextItemViewModel> Keybindings()
126+
private Dictionary<KeyChord, CommandContextItemViewModel> Keybindings()
125127
{
126-
if (CurrentContextMenu is null)
128+
var result = new Dictionary<KeyChord, CommandContextItemViewModel>();
129+
130+
var menu = CurrentContextMenu;
131+
if (menu is null)
132+
{
133+
return result;
134+
}
135+
136+
foreach (var item in menu)
127137
{
128-
return [];
138+
if (item is CommandContextItemViewModel cmd && cmd.HasRequestedShortcut)
139+
{
140+
var key = cmd.RequestedShortcut ?? new KeyChord(0, 0, 0);
141+
var added = result.TryAdd(key, cmd);
142+
if (!added)
143+
{
144+
Logger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'");
145+
}
146+
}
129147
}
130148

131-
return CurrentContextMenu
132-
.OfType<CommandContextItemViewModel>()
133-
.Where(c => c.HasRequestedShortcut)
134-
.ToDictionary(
135-
c => c.RequestedShortcut ?? new KeyChord(0, 0, 0),
136-
c => c);
149+
return result;
137150
}
138151

139152
public ContextKeybindingResult? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key)
140153
{
141154
var keybindings = Keybindings();
142-
if (keybindings is not null)
155+
156+
// Does the pressed key match any of the keybindings?
157+
var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0);
158+
if (keybindings.TryGetValue(pressedKeyChord, out var item))
143159
{
144-
// Does the pressed key match any of the keybindings?
145-
var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0);
146-
if (keybindings.TryGetValue(pressedKeyChord, out var item))
147-
{
148-
return InvokeCommand(item);
149-
}
160+
return InvokeCommand(item);
150161
}
151162

152163
return null;

src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System.ComponentModel;
6+
using ManagedCommon;
67
using Microsoft.CommandPalette.Extensions;
8+
using Microsoft.CommandPalette.Extensions.Toolkit;
79

810
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
911

@@ -32,12 +34,28 @@ public interface IContextMenuContext : INotifyPropertyChanged
3234
/// that have a shortcut key set.</returns>
3335
public Dictionary<KeyChord, CommandContextItemViewModel> Keybindings()
3436
{
35-
return MoreCommands
36-
.OfType<CommandContextItemViewModel>()
37-
.Where(c => c.HasRequestedShortcut)
38-
.ToDictionary(
39-
c => c.RequestedShortcut ?? new KeyChord(0, 0, 0),
40-
c => c);
37+
var result = new Dictionary<KeyChord, CommandContextItemViewModel>();
38+
39+
var menu = MoreCommands;
40+
if (menu is null)
41+
{
42+
return result;
43+
}
44+
45+
foreach (var item in menu)
46+
{
47+
if (item is CommandContextItemViewModel cmd && cmd.HasRequestedShortcut)
48+
{
49+
var key = cmd.RequestedShortcut ?? new KeyChord(0, 0, 0);
50+
var added = result.TryAdd(key, cmd);
51+
if (!added)
52+
{
53+
Logger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'");
54+
}
55+
}
56+
}
57+
58+
return result;
4159
}
4260
}
4361

src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,12 @@ public partial class EvilSamplesPage : ListPage
219219
}
220220
],
221221
},
222-
222+
new ListItem(new EvilDuplicateRequestedShortcut())
223+
{
224+
Title = "Evil keyboard shortcuts",
225+
Subtitle = "Two commands with the same shortcut and more...",
226+
Icon = new IconInfo("\uE765"),
227+
}
223228
];
224229

225230
public EvilSamplesPage()
@@ -414,3 +419,42 @@ public override IListItem[] GetItems()
414419
}
415420
}
416421
}
422+
423+
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")]
424+
internal sealed partial class EvilDuplicateRequestedShortcut : ListPage
425+
{
426+
private readonly IListItem[] _items =
427+
[
428+
new ListItem(new NoOpCommand())
429+
{
430+
Title = "I'm evil!",
431+
Subtitle = "I have multiple commands sharing the same keyboard shortcut",
432+
MoreCommands = [
433+
new CommandContextItem(new AnonymousCommand(() => new ToastStatusMessage("Me too executed").Show())
434+
{
435+
Result = CommandResult.KeepOpen(),
436+
})
437+
{
438+
Title = "Me too",
439+
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1),
440+
},
441+
new CommandContextItem(new AnonymousCommand(() => new ToastStatusMessage("Me three executed").Show())
442+
{
443+
Result = CommandResult.KeepOpen(),
444+
})
445+
{
446+
Title = "Me three",
447+
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1),
448+
},
449+
],
450+
},
451+
];
452+
453+
public override IListItem[] GetItems() => _items;
454+
455+
public EvilDuplicateRequestedShortcut()
456+
{
457+
Icon = new IconInfo(string.Empty);
458+
Name = "Open";
459+
}
460+
}

src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
namespace Microsoft.CommandPalette.Extensions.Toolkit;
88

9-
public partial class KeyChordHelpers
9+
public static partial class KeyChordHelpers
1010
{
1111
public static KeyChord FromModifiers(
1212
bool ctrl = false,
@@ -34,4 +34,28 @@ public static KeyChord FromModifiers(
3434
{
3535
return FromModifiers(ctrl, alt, shift, win, (int)vkey, scanCode);
3636
}
37+
38+
public static string FormatForDebug(KeyChord value)
39+
{
40+
var result = string.Empty;
41+
42+
if (value.Modifiers.HasFlag(VirtualKeyModifiers.Control))
43+
{
44+
result += "Ctrl+";
45+
}
46+
47+
if (value.Modifiers.HasFlag(VirtualKeyModifiers.Shift))
48+
{
49+
result += "Shift+";
50+
}
51+
52+
if (value.Modifiers.HasFlag(VirtualKeyModifiers.Menu))
53+
{
54+
result += "Alt+";
55+
}
56+
57+
result += (VirtualKey)value.Vkey;
58+
59+
return result;
60+
}
3761
}

0 commit comments

Comments
 (0)