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) {