From 587385d879fe73b3f4c0585cc1b29428be79a14f Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Thu, 12 Feb 2026 17:28:21 +0800
Subject: [PATCH] [Settings] Implement singleton pattern for
ShortcutConflictWindow (#42440)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Fixes issue where multiple ShortcutConflictWindow instances could be
opened simultaneously. The window now follows the same singleton pattern
as OobeWindow - only one instance can exist at a time, and attempting to
open another brings the existing window to the foreground.
## Changes
Implemented singleton management for `ShortcutConflictWindow` following
the established pattern used by `OobeWindow`:
### App.xaml.cs
- Added static field to store the singleton window instance
- Added `GetShortcutConflictWindow()`, `SetShortcutConflictWindow()`,
and `ClearShortcutConflictWindow()` methods for lifecycle management
### ShortcutConflictWindow.xaml.cs
- Updated `WindowEx_Closed` event handler to call
`App.ClearShortcutConflictWindow()` to properly clean up the singleton
reference when the window is closed
### Updated all three entry points that create ShortcutConflictWindow:
- **ShortcutConflictControl.xaml.cs** (Dashboard conflict warning)
- **ShortcutControl.xaml.cs** (Settings page shortcut controls)
- **OobeOverview.xaml.cs** (OOBE overview page)
Each location now checks if a window already exists using
`App.GetShortcutConflictWindow()`:
- If no window exists, creates a new one and registers it via
`App.SetShortcutConflictWindow()`
- If a window already exists, simply calls `Activate()` to bring it to
the foreground
## Testing
The fix ensures that:
- ✅ Only one ShortcutConflictWindow can be open at a time
- ✅ Clicking the shortcut conflict button when a window is already open
activates the existing window instead of creating a duplicate
- ✅ The window reference is properly cleared when closed, allowing a new
instance to be created in future interactions
Fixes #[issue_number]
> [!WARNING]
>
>
> Firewall rules blocked me from connecting to one or more
addresses (expand for details)
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `i1qvsblobprodcus353.vsblob.vsassets.io`
> - Triggering command: `dotnet build PowerToys.Settings.csproj
--configuration Release` (dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/microsoft/PowerToys/settings/copilot/coding_agent)
(admins only)
>
>
Original prompt
>
> ----
>
> *This section details on the original issue you should resolve*
>
> [Settings] Single Shortcuts Conflicts
window
> ### Microsoft PowerToys version
>
> 0.95.0
>
> ### Installation method
>
> GitHub
>
> ### Area(s) with issue?
>
> Settings
>
> ### Steps to reproduce
>
> Multiple shortcut conflicts window can be launched.
> Should have the same behavior of OOBE window.
> If shortcut conflicts window is already opened, pressing the button
should bring the window in foreground.
>
> ### ✔️ Expected Behavior
>
> Single shortcuts conflicts window
>
> ### ❌ Actual Behavior
>
> Multiple shortcut conflicts window can be launched
>
> ### Additional Information
>
> _No response_
>
> ### Other Software
>
> _No response_
>
> Settings ShortcutConflictWindow should have the
same behavior of OobeWindow.
> When ShortcutConflictWindow is already opened, activate that window
instead of opening another one.
>
> ## Comments on the Issue (you are @copilot in this section)
>
>
>
>
Fixes microsoft/PowerToys#42437
---
💬 Share your feedback on Copilot coding agent for the chance to win a
$200 gift card! Click
[here](https://survey3.medallia.com/?EAHeSx-AP01bZqG0Ld9QLQ) to start
the survey.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: davidegiacometti <25966642+davidegiacometti@users.noreply.github.com>
Co-authored-by: Davide Giacometti
Co-authored-by: Niels Laute
Co-authored-by: vanzue
---
.../Settings.UI/SettingsXAML/App.xaml.cs | 26 ++++++++++-
.../Dashboard/ShortcutConflictControl.xaml.cs | 9 +---
.../Dashboard/ShortcutConflictWindow.xaml.cs | 20 +++++++--
.../ShortcutControl/ShortcutControl.xaml.cs | 5 +--
.../SettingsXAML/MainWindow.xaml.cs | 2 +-
.../SettingsXAML/Views/DashboardPage.xaml.cs | 1 +
.../ViewModels/DashboardViewModel.cs | 43 +++++++++++++++++++
7 files changed, 88 insertions(+), 18 deletions(-)
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs
index 06db073a12..071c782901 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs
@@ -17,6 +17,7 @@ using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
using Microsoft.PowerToys.Settings.UI.Services;
+using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Xaml;
@@ -35,6 +36,8 @@ namespace Microsoft.PowerToys.Settings.UI
private ScoobeWindow scoobeWindow;
+ private ShortcutConflictWindow shortcutConflictWindow;
+
private enum Arguments
{
PTPipeName = 1,
@@ -336,10 +339,10 @@ namespace Microsoft.PowerToys.Settings.UI
return settingsWindow;
}
- public static bool IsOobeOrScoobeOpen()
+ public static bool IsSecondaryWindowOpen()
{
var app = (App)Current;
- return app.oobeWindow != null || app.scoobeWindow != null;
+ return app.oobeWindow != null || app.scoobeWindow != null || app.shortcutConflictWindow != null;
}
public void OpenScoobe()
@@ -384,6 +387,25 @@ namespace Microsoft.PowerToys.Settings.UI
}
}
+ public void OpenShortcutConflictWindow()
+ {
+ if (shortcutConflictWindow == null)
+ {
+ shortcutConflictWindow = new ShortcutConflictWindow();
+
+ shortcutConflictWindow.Closed += (_, _) =>
+ {
+ shortcutConflictWindow = null;
+ };
+
+ shortcutConflictWindow.Activate();
+ }
+ else
+ {
+ WindowHelpers.BringToForeground(shortcutConflictWindow.GetWindowHandle());
+ }
+ }
+
public static Type GetPage(string settingWindow)
{
switch (settingWindow)
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs
index d7806f17ea..1bb42d8f15 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs
@@ -2,10 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using System;
-using System.Collections.Generic;
using System.ComponentModel;
-using System.Linq;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
@@ -154,11 +151,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
ConflictCount = this.ConflictCount,
});
- // Create and show the new window instead of dialog
- var conflictWindow = new ShortcutConflictWindow();
-
- // Show the window
- conflictWindow.Activate();
+ ((App)App.Current)!.OpenShortcutConflictWindow();
}
}
}
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs
index 672b3b51a4..ec6aac76d1 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs
@@ -2,16 +2,13 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
-using System;
using CommunityToolkit.WinUI.Controls;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
-using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.PowerToys.Settings.UI.Views;
-using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -26,7 +23,11 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
public ShortcutConflictWindow()
{
+ App.ThemeService.ThemeChanged += OnThemeChanged;
+ App.ThemeService.ApplyTheme();
+
var settingsUtils = SettingsUtils.Default;
+
ViewModel = new ShortcutConflictViewModel(
settingsUtils,
SettingsRepository.GetInstance(settingsUtils),
@@ -50,6 +51,11 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
ViewModel.OnPageLoaded();
}
+ private void OnThemeChanged(object sender, ElementTheme theme)
+ {
+ WindowHelper.SetTheme(this, theme);
+ }
+
private void CenterOnScreen()
{
var displayArea = DisplayArea.GetFromWindowId(this.AppWindow.Id, DisplayAreaFallback.Nearest);
@@ -127,6 +133,14 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
private void WindowEx_Closed(object sender, WindowEventArgs args)
{
ViewModel?.Dispose();
+
+ var mainWindow = App.GetSettingsWindow();
+ if (mainWindow != null)
+ {
+ mainWindow.CloseHiddenWindow();
+ }
+
+ App.ThemeService.ThemeChanged -= OnThemeChanged;
}
private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args)
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs
index ba053e1124..488c68a3ae 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs
@@ -9,7 +9,6 @@ using System.Linq;
using CommunityToolkit.WinUI;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
-using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
@@ -300,9 +299,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
// Close the current shortcut dialog
shortcutDialog.Hide();
- // Create and show the ShortcutConflictWindow
- var conflictWindow = new ShortcutConflictWindow();
- conflictWindow.Activate();
+ ((App)App.Current)!.OpenShortcutConflictWindow();
}
private void UpdateKeyVisualStyles()
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs
index 20834e8f44..ba417ad066 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs
@@ -162,7 +162,7 @@ namespace Microsoft.PowerToys.Settings.UI
var hWnd = WindowNative.GetWindowHandle(this);
WindowHelper.SerializePlacement(hWnd);
- if (!App.IsOobeOrScoobeOpen())
+ if (!App.IsSecondaryWindowOpen())
{
App.ClearSettingsWindow();
}
diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs
index c697199249..3e4d122379 100644
--- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs
+++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs
@@ -39,6 +39,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
DataContext = ViewModel;
Loaded += (s, e) => ViewModel.OnPageLoaded();
+ Unloaded += (s, e) => ViewModel?.Dispose();
}
public void RefreshEnabledState()
diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
index 418d6b5964..6301465996 100644
--- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
+++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs
@@ -55,6 +55,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Flag to prevent toggle operations during sorting to avoid race conditions.
private bool _isSorting;
+ private bool _isDisposed;
private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData();
@@ -132,8 +133,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private void OnSettingsChanged(GeneralSettings newSettings)
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
dispatcher.TryEnqueue(() =>
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
generalSettingsConfig = newSettings;
// Update local field and notify UI if sort order changed
@@ -149,8 +160,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e)
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
dispatcher.TryEnqueue(() =>
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
var allConflictData = e.Conflicts;
foreach (var inAppConflict in allConflictData.InAppConflicts)
{
@@ -363,6 +384,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
///
public void ModuleEnabledChangedOnSettingsPage()
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
// Ignore if this was triggered by a UI change that we're already handling.
if (_isUpdatingFromUI)
{
@@ -391,6 +417,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
///
private void RefreshShortcutModules()
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ if (!dispatcher.HasThreadAccess)
+ {
+ _ = dispatcher.TryEnqueue(DispatcherQueuePriority.Normal, RefreshShortcutModules);
+ return;
+ }
+
ShortcutModules.Clear();
ActionModules.Clear();
@@ -804,6 +841,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public override void Dispose()
{
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
base.Dispose();
if (_settingsRepository != null)
{