Settings: Add pending changes tracking and apply confirmation dialog (#14425)

* initial commit * cp * cp * pr feedback * cp * pr feedback * pr feedback * cp * pr feedback * refactor * pr feedback * cp * Move Apply Changes button to ShellPage to deduplicate across settings pages * PR feedback 1 --------- Co-authored-by: Darshak Bhatti <dabhatti@micorsoft.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

Darshak Bhatti committed Apr 20, 2026 at 18:24 UTC 37a3aa5b139c37ce1edd75c558c2b35851075eae
8 files changed +614 -71
localization/strings/en-US/Resources.resw
+35
@@ -1877,6 +1877,41 @@ You can also access more VS Code Remote options through the command palette with
1877 <data name="Settings_VMIdleTimeoutTextBox.AutomationProperties.HelpText" xml:space="preserve">
1878 <value>The number of milliseconds that a VM is idle, before it is shut down.</value>
1879 </data>
1880 + <data name="Settings_ApplyChangesButton.Content" xml:space="preserve">
1881 + <value>Apply changes</value>
1882 + </data>
1883 + <data name="Settings_ApplyChangesDialogTitle" xml:space="preserve">
1884 + <value>WSL Shutdown Required</value>
1885 + </data>
1886 + <data name="Settings_ApplyChangesDialogDescription" xml:space="preserve">
1887 + <value>Changed WSL settings require a WSL shutdown and restart to finalize. Shutting down will cause any active WSL sessions to terminate.</value>
1888 + </data>
1889 + <data name="Settings_ApplyChangesDialogShutdownButton" xml:space="preserve">
1890 + <value>Shutdown WSL now</value>
1891 + </data>
1892 + <data name="Settings_ApplyChangesDialogLaterButton" xml:space="preserve">
1893 + <value>Later</value>
1894 + </data>
1895 + <data name="Settings_ApplyChangesDialogFailedTitle" xml:space="preserve">
1896 + <value>Unable to apply changes</value>
1897 + </data>
1898 + <data name="Settings_ApplyChangesDialogCloseButton" xml:space="preserve">
1899 + <value>Close</value>
1900 + </data>
1901 + <data name="Settings_ApplyChangesDialogCommitFailed" xml:space="preserve">
1902 + <value>Failed to save pending settings changes. Error code: {0}</value>
1903 + <comment>{Locked="{0}"}Format placeholder for Win32 error code</comment>
1904 + </data>
1905 + <data name="Settings_ApplyChangesDialogShutdownFailed" xml:space="preserve">
1906 + <value>Settings were saved, but shutting down WSL failed: {0}</value>
1907 + <comment>{Locked="{0}"}Format placeholder for exception message</comment>
1908 + </data>
1909 + <data name="Settings_BooleanTrueText" xml:space="preserve">
1910 + <value>On</value>
1911 + </data>
1912 + <data name="Settings_BooleanFalseText" xml:space="preserve">
1913 + <value>Off</value>
1914 + </data>
1915 <data name="Settings_OOBEVSIntegration.Title" xml:space="preserve">
1916 <value>Build, run, debug, and profile your apps running on WSL from Visual Studio</value>
1917 </data>
src/windows/wslsettings/CMakeLists.txt
+1
@@ -127,6 +127,7 @@ add_executable(
127 Views/Settings/NetworkingPage.xaml.cs
128 Views/Settings/OptionalFeaturesPage.xaml
129 Views/Settings/OptionalFeaturesPage.xaml.cs
130 + Views/Settings/SettingsApplyHelper.cs
131 Views/Settings/ShellPage.xaml
132 Views/Settings/ShellPage.xaml.cs
133 Windows/OOBEWindow.xaml
src/windows/wslsettings/Contracts/Services/IWslConfigService.cs
+67
@@ -5,9 +5,33 @@ namespace WslSettings.Contracts.Services;
5 public interface IWslConfigService
6 {
7 IWslConfigSetting GetWslConfigSetting(WslConfigEntry wslConfigEntry, bool defaultSetting = false);
8 +
9 + /// <summary>
10 + /// Stages a config value change in memory. The value is only written to disk when
11 + /// <see cref="CommitPendingChanges"/> is called.
12 + /// </summary>
13 uint SetWslConfigSetting(IWslConfigSetting setting);
14 +
15 + /// <summary>
16 + /// True when there are in-app changes that have not yet been committed to disk.
17 + /// </summary>
18 + bool HasPendingChanges { get; }
19 +
20 + /// <summary>
21 + /// Returns pending changes as (entry, new value) pairs for UI display.
22 + /// </summary>
23 + IReadOnlyList<WslConfigPendingChange> GetPendingChanges();
24 + uint CommitPendingChanges();
25 public delegate void WslConfigChangedEventHandler();
26 event WslConfigChangedEventHandler WslConfigChanged;
27 + public delegate void PendingChangesChangedEventHandler();
28 + event PendingChangesChangedEventHandler PendingChangesChanged;
29 +}
30 +
31 +public sealed class WslConfigPendingChange
32 +{
33 + public WslConfigEntry ConfigEntry { get; init; }
34 + public required object PendingValue { get; init; }
35 }
36
37 public interface IWslConfigSetting
@@ -21,4 +45,47 @@ public interface IWslConfigSetting
45 MemoryReclaimMode MemoryReclaimModeValue { get; }
46 uint SetValue(object? value);
47 bool Equals(object? obj);
48 +}
49 +
50 +/// <summary>
51 +/// Describes which union field a <see cref="WslConfigEntry"/> uses inside the native WslConfigSetting struct.
52 +/// </summary>
53 +public enum WslConfigValueKind
54 +{
55 + Bool,
56 + Int32,
57 + UInt64,
58 + String,
59 + NetworkingConfiguration,
60 + MemoryReclaimMode,
61 +}
62 +
63 +public static class WslConfigEntryExtensions
64 +{
65 + /// <summary>
66 + /// Returns the <see cref="WslConfigValueKind"/> that describes which union field this entry uses.
67 + /// Keep this in sync with the native WslConfigSetting union in WslCoreConfigInterface.h.
68 + /// </summary>
69 + public static WslConfigValueKind GetValueKind(this WslConfigEntry entry) => entry switch
70 + {
71 + WslConfigEntry.SwapFilePath or
72 + WslConfigEntry.IgnoredPorts or
73 + WslConfigEntry.KernelPath or
74 + WslConfigEntry.SystemDistroPath or
75 + WslConfigEntry.KernelModulesPath => WslConfigValueKind.String,
76 +
77 + WslConfigEntry.ProcessorCount or
78 + WslConfigEntry.InitialAutoProxyTimeout or
79 + WslConfigEntry.VMIdleTimeout => WslConfigValueKind.Int32,
80 +
81 + WslConfigEntry.MemorySizeBytes or
82 + WslConfigEntry.SwapSizeBytes or
83 + WslConfigEntry.VhdSizeBytes => WslConfigValueKind.UInt64,
84 +
85 + WslConfigEntry.NetworkingMode => WslConfigValueKind.NetworkingConfiguration,
86 +
87 + WslConfigEntry.AutoMemoryReclaim => WslConfigValueKind.MemoryReclaimMode,
88 +
89 + _ => WslConfigValueKind.Bool,
90 + };
91 }
\ No newline at end of file
src/windows/wslsettings/Services/WslConfigService.cs
+255 -69
@@ -1,4 +1,4 @@
1 -// Copyright (C) Microsoft Corporation. All rights reserved.
1 +// Copyright (C) Microsoft Corporation. All rights reserved.
2
3 using WslSettings.Contracts.Services;
4 using static WslSettings.Contracts.Services.IWslConfigService;
@@ -12,6 +12,10 @@ public class WslConfigService : IWslConfigService, IDisposable
12 private readonly object? _wslCoreConfigInterfaceLockObj = null;
13 private FileSystemWatcher? _wslConfigFileSystemWatcher = null;
14
15 + // Pending changes: stores only entries the user has changed in-app but not yet committed.
16 + // Values are plain managed objects (bool, int, ulong, string, or enum) — no native clones needed.
17 + private readonly Dictionary<WslConfigEntry, object> _pendingValues = new();
18 +
19 public WslConfigService()
20 {
21 string filePath = WslCoreConfigInterface.GetWslConfigFilePath();
@@ -61,31 +65,164 @@ public class WslConfigService : IWslConfigService, IDisposable
65
66 public IWslConfigSetting GetWslConfigSetting(WslConfigEntry wslConfigEntry, bool defaultSetting)
67 {
64 - WslConfigSettingManaged? wslConfigSetting = null;
65 - lock (_wslCoreConfigInterfaceLockObj!)
68 + if (defaultSetting)
69 {
67 - wslConfigSetting = new WslConfigSettingManaged(WslCoreConfigInterface.GetWslConfigSetting(defaultSetting ? _wslConfigDefaults : _wslConfig, wslConfigEntry));
70 + return GetPersistedWslConfigSetting(wslConfigEntry, defaultSetting: true);
71 }
72
70 - return wslConfigSetting;
73 + lock (_wslCoreConfigInterfaceLockObj!)
74 + {
75 + if (_pendingValues.TryGetValue(wslConfigEntry, out var pendingValue))
76 + {
77 + // Build a native setting from the pending managed value for the caller
78 + var setting = GetPersistedWslConfigSetting(wslConfigEntry, defaultSetting: false);
79 + setting.SetValueDirect(pendingValue);
80 + return setting;
81 + }
82 +
83 + return GetPersistedWslConfigSetting(wslConfigEntry, defaultSetting: false);
84 + }
85 }
86
87 public uint SetWslConfigSetting(IWslConfigSetting wslConfigSetting)
88 {
75 - var wslConfigSettingsManaged = wslConfigSetting as WslConfigSettingManaged;
76 - if (wslConfigSettingsManaged == null)
89 + var settingManaged = wslConfigSetting as WslConfigSettingManaged;
90 + if (settingManaged == null)
91 {
78 - throw new ArgumentNullException("wslConfigSetting");
92 + throw new ArgumentNullException(nameof(wslConfigSetting));
93 }
94
95 + bool pendingStateChanged;
96 + lock (_wslCoreConfigInterfaceLockObj!)
97 + {
98 + var hadPendingBefore = _pendingValues.Count > 0;
99 +
100 + var persisted = GetPersistedWslConfigSetting(settingManaged.ConfigEntry, defaultSetting: false);
101 + try
102 + {
103 + bool isChanged = !persisted.Equals(settingManaged.GetValueAsObject());
104 +
105 + if (isChanged)
106 + {
107 + _pendingValues[settingManaged.ConfigEntry] = settingManaged.GetValueAsObject();
108 + }
109 + else
110 + {
111 + _pendingValues.Remove(settingManaged.ConfigEntry);
112 + }
113 + }
114 + finally
115 + {
116 + persisted.ConfigSetting.Dispose();
117 + }
118 +
119 + var hasPendingAfter = _pendingValues.Count > 0;
120 + pendingStateChanged = hadPendingBefore != hasPendingAfter;
121 + }
122 +
123 + if (pendingStateChanged)
124 + {
125 + _onPendingChangesChangedHandler?.Invoke();
126 + }
127 +
128 + return 0;
129 + }
130 +
131 + public bool HasPendingChanges
132 + {
133 + get
134 + {
135 + lock (_wslCoreConfigInterfaceLockObj!)
136 + {
137 + return _pendingValues.Count > 0;
138 + }
139 + }
140 + }
141 +
142 + public IReadOnlyList<WslConfigPendingChange> GetPendingChanges()
143 + {
144 + lock (_wslCoreConfigInterfaceLockObj!)
145 + {
146 + var changes = new List<WslConfigPendingChange>(_pendingValues.Count);
147 + foreach (var (entry, value) in _pendingValues)
148 + {
149 + changes.Add(new WslConfigPendingChange
150 + {
151 + ConfigEntry = entry,
152 + PendingValue = value,
153 + });
154 + }
155 + return changes;
156 + }
157 + }
158 +
159 + public uint CommitPendingChanges()
160 + {
161 uint result = 0;
162 + bool pendingStateChanged;
163 +
164 lock (_wslCoreConfigInterfaceLockObj!)
165 {
166 + if (_pendingValues.Count == 0)
167 + {
168 + return 0;
169 + }
170 +
171 _wslConfigFileSystemWatcher!.EnableRaisingEvents = false;
85 - result = WslCoreConfigInterface.SetWslConfigSetting(_wslConfig, wslConfigSettingsManaged.ConfigSetting);
86 - _wslConfigFileSystemWatcher!.EnableRaisingEvents = true;
172 + try
173 + {
174 + var committed = new List<WslConfigEntry>();
175 + foreach (var (entry, value) in _pendingValues)
176 + {
177 + var setting = GetPersistedWslConfigSetting(entry, defaultSetting: false);
178 + try
179 + {
180 + setting.SetValueDirect(value);
181 + result = WslCoreConfigInterface.SetWslConfigSetting(_wslConfig, setting.ConfigSetting);
182 + }
183 + finally
184 + {
185 + setting.ConfigSetting.Dispose();
186 + }
187 +
188 + if (result != 0)
189 + {
190 + break;
191 + }
192 +
193 + committed.Add(entry);
194 + }
195 +
196 + ReloadConfig_NoLock();
197 +
198 + if (result == 0)
199 + {
200 + _pendingValues.Clear();
201 + }
202 + else
203 + {
204 + // Partial failure - only remove successfully-committed entries
205 + // so unapplied entries remain pending.
206 + foreach (var entry in committed)
207 + {
208 + _pendingValues.Remove(entry);
209 + }
210 + }
211 + }
212 + finally
213 + {
214 + _wslConfigFileSystemWatcher!.EnableRaisingEvents = true;
215 + }
216 +
217 + // We entered with pending changes. Fire only if they're now empty (full success).
218 + pendingStateChanged = _pendingValues.Count == 0;
219 }
220
221 + if (pendingStateChanged)
222 + {
223 + _onPendingChangesChangedHandler?.Invoke();
224 + }
225 + _onWslConfigChangedHandler?.Invoke();
226 return result;
227 }
228
@@ -102,18 +239,55 @@ public class WslConfigService : IWslConfigService, IDisposable
239 }
240 }
241
242 + private PendingChangesChangedEventHandler? _onPendingChangesChangedHandler = null;
243 + public event PendingChangesChangedEventHandler PendingChangesChanged
244 + {
245 + add
246 + {
247 + _onPendingChangesChangedHandler += value;
248 + }
249 + remove
250 + {
251 + _onPendingChangesChangedHandler -= value;
252 + }
253 + }
254 +
255 private void OnWslConfigFileChanged(object sender, FileSystemEventArgs e)
256 {
257 + bool hadPending;
258 lock (_wslCoreConfigInterfaceLockObj!)
259 {
260 + hadPending = _pendingValues.Count > 0;
261 _wslConfigFileSystemWatcher!.EnableRaisingEvents = false;
110 - WslCoreConfigInterface.FreeWslConfig(_wslConfig);
111 - _wslConfig = WslCoreConfigInterface.CreateWslConfig(WslCoreConfigInterface.GetWslConfigFilePath());
112 - _wslConfigFileSystemWatcher!.EnableRaisingEvents = true;
262 + try
263 + {
264 + ReloadConfig_NoLock();
265 + _pendingValues.Clear();
266 + }
267 + finally
268 + {
269 + _wslConfigFileSystemWatcher!.EnableRaisingEvents = true;
270 + }
271 }
272
273 + if (hadPending)
274 + {
275 + _onPendingChangesChangedHandler?.Invoke();
276 + }
277 _onWslConfigChangedHandler?.Invoke();
278 }
279 +
280 + // Read a single setting from the native config layer (either the user's .wslconfig or built-in defaults).
281 + private WslConfigSettingManaged GetPersistedWslConfigSetting(WslConfigEntry wslConfigEntry, bool defaultSetting)
282 + {
283 + return new WslConfigSettingManaged(WslCoreConfigInterface.GetWslConfigSetting(defaultSetting ? _wslConfigDefaults : _wslConfig, wslConfigEntry));
284 + }
285 +
286 + private void ReloadConfig_NoLock()
287 + {
288 + WslCoreConfigInterface.FreeWslConfig(_wslConfig);
289 + _wslConfig = WslCoreConfigInterface.CreateWslConfig(WslCoreConfigInterface.GetWslConfigFilePath());
290 + }
291 }
292
293 public partial class WslConfigSettingManaged : IWslConfigSetting
@@ -137,41 +311,79 @@ public partial class WslConfigSettingManaged : IWslConfigSetting
311 public NetworkingConfiguration NetworkingConfigurationValue { get { return ConfigSetting.NetworkingConfigurationValue; } }
312 public MemoryReclaimMode MemoryReclaimModeValue { get { return ConfigSetting.MemoryReclaimModeValue; } }
313
140 -#nullable enable
141 - public uint SetValue(object? value)
314 + public object GetValueAsObject()
315 {
143 - if (value == null)
316 + switch (ConfigSetting.ConfigEntry.GetValueKind())
317 {
145 - throw new ArgumentNullException("value");
318 + case WslConfigValueKind.String:
319 + return ConfigSetting.StringValue;
320 + case WslConfigValueKind.Int32:
321 + return ConfigSetting.Int32Value;
322 + case WslConfigValueKind.UInt64:
323 + return ConfigSetting.UInt64Value;
324 + case WslConfigValueKind.NetworkingConfiguration:
325 + return ConfigSetting.NetworkingConfigurationValue;
326 + case WslConfigValueKind.MemoryReclaimMode:
327 + return ConfigSetting.MemoryReclaimModeValue;
328 + default:
329 + return ConfigSetting.BoolValue;
330 }
331 + }
332
148 - if ("".GetType() == value.GetType())
149 - {
150 - ConfigSetting.StringValue = (string)value;
151 - }
152 - else if (ConfigSetting.UInt64Value.GetType() == value.GetType())
153 - {
154 - ConfigSetting.UInt64Value = (ulong)value;
155 - }
156 - else if (ConfigSetting.Int32Value.GetType() == value.GetType())
157 - {
158 - ConfigSetting.Int32Value = (int)value;
159 - }
160 - else if (ConfigSetting.BoolValue.GetType() == value.GetType())
161 - {
162 - ConfigSetting.BoolValue = (bool)value;
163 - }
164 - else if (ConfigSetting.NetworkingConfigurationValue.GetType() == value.GetType())
333 + // Apply a plain managed value to the native setting without going through the service.
334 + public void SetValueDirect(object value)
335 + {
336 + switch (ConfigSetting.ConfigEntry.GetValueKind())
337 {
166 - ConfigSetting.NetworkingConfigurationValue = (NetworkingConfiguration)value;
338 + case WslConfigValueKind.String:
339 + ConfigSetting.StringValue = (string)value;
340 + break;
341 + case WslConfigValueKind.Int32:
342 + ConfigSetting.Int32Value = (int)value;
343 + break;
344 + case WslConfigValueKind.UInt64:
345 + ConfigSetting.UInt64Value = (ulong)value;
346 + break;
347 + case WslConfigValueKind.NetworkingConfiguration:
348 + ConfigSetting.NetworkingConfigurationValue = (NetworkingConfiguration)value;
349 + break;
350 + case WslConfigValueKind.MemoryReclaimMode:
351 + ConfigSetting.MemoryReclaimModeValue = (MemoryReclaimMode)value;
352 + break;
353 + default:
354 + ConfigSetting.BoolValue = (bool)value;
355 + break;
356 }
168 - else if (ConfigSetting.MemoryReclaimModeValue.GetType() == value.GetType())
357 + }
358 +
359 +#nullable enable
360 + public uint SetValue(object? value)
361 + {
362 + if (value == null)
363 {
170 - ConfigSetting.MemoryReclaimModeValue = (MemoryReclaimMode)value;
364 + throw new ArgumentNullException(nameof(value));
365 }
172 - else
366 +
367 + switch (ConfigSetting.ConfigEntry.GetValueKind())
368 {
174 - throw new InvalidDataException();
369 + case WslConfigValueKind.String:
370 + ConfigSetting.StringValue = (string)value;
371 + break;
372 + case WslConfigValueKind.Int32:
373 + ConfigSetting.Int32Value = (int)value;
374 + break;
375 + case WslConfigValueKind.UInt64:
376 + ConfigSetting.UInt64Value = (ulong)value;
377 + break;
378 + case WslConfigValueKind.NetworkingConfiguration:
379 + ConfigSetting.NetworkingConfigurationValue = (NetworkingConfiguration)value;
380 + break;
381 + case WslConfigValueKind.MemoryReclaimMode:
382 + ConfigSetting.MemoryReclaimModeValue = (MemoryReclaimMode)value;
383 + break;
384 + default:
385 + ConfigSetting.BoolValue = (bool)value;
386 + break;
387 }
388
389 return App.GetService<IWslConfigService>().SetWslConfigSetting(this);
@@ -181,7 +393,7 @@ public partial class WslConfigSettingManaged : IWslConfigSetting
393 {
394 if (value == null)
395 {
184 - throw new ArgumentNullException("value");
396 + throw new ArgumentNullException(nameof(value));
397 }
398
399 // Special handling for byte values. Compare using MB since in the UI the user works with MB.
@@ -192,38 +404,12 @@ public partial class WslConfigSettingManaged : IWslConfigSetting
404 return ((ulong)value / Constants.MB) == (UInt64Value / Constants.MB);
405 }
406
195 - if ("".GetType() == value.GetType())
196 - {
197 - return ConfigSetting.StringValue == (string)value;
198 - }
199 - else if (ConfigSetting.UInt64Value.GetType() == value.GetType())
200 - {
201 - return ConfigSetting.UInt64Value == (ulong)value;
202 - }
203 - else if (ConfigSetting.Int32Value.GetType() == value.GetType())
204 - {
205 - return ConfigSetting.Int32Value == (int)value;
206 - }
207 - else if (ConfigSetting.BoolValue.GetType() == value.GetType())
208 - {
209 - return ConfigSetting.BoolValue == (bool)value;
210 - }
211 - else if (ConfigSetting.NetworkingConfigurationValue.GetType() == value.GetType())
212 - {
213 - return ConfigSetting.NetworkingConfigurationValue == (NetworkingConfiguration)value;
214 - }
215 - else if (ConfigSetting.MemoryReclaimModeValue.GetType() == value.GetType())
216 - {
217 - return ConfigSetting.MemoryReclaimModeValue == (MemoryReclaimMode)value;
218 - }
219 - else
220 - {
221 - throw new InvalidDataException();
222 - }
407 + // object.Equals handles null on either side (returns true if both null, false if one null).
408 + return object.Equals(GetValueAsObject(), value);
409 }
410
411 public override int GetHashCode()
412 {
413 return base.GetHashCode();
414 }
229 -}
\ No newline at end of file
415 +}
src/windows/wslsettings/Views/Settings/OptionalFeaturesPage.xaml.cs
+1 -1
@@ -62,4 +62,4 @@ public sealed partial class OptionalFeaturesPage : Page
62 TextBox? textBox = sender as TextBox;
63 ViewModel.SetVMIdleTimeout_ResetEnabled(textBox!.Text);
64 }
65 -}
\ No newline at end of file
65 +}
src/windows/wslsettings/Views/Settings/SettingsApplyHelper.cs new
+221
@@ -0,0 +1,221 @@
1 +// Copyright (C) Microsoft Corporation. All rights reserved.
2 +
3 +using System;
4 +using System.ComponentModel;
5 +using System.Diagnostics;
6 +using System.Runtime.InteropServices;
7 +using Microsoft.UI.Xaml;
8 +using Microsoft.UI.Xaml.Controls;
9 +using WslSettings.Contracts.Services;
10 +
11 +namespace WslSettings.Views.Settings;
12 +
13 +internal static class SettingsApplyHelper
14 +{
15 + public static async Task ShowApplyChangesDialogAsync(XamlRoot xamlRoot)
16 + {
17 + var wslConfigService = App.GetService<IWslConfigService>();
18 + var pendingChanges = wslConfigService.GetPendingChanges();
19 + if (pendingChanges.Count == 0)
20 + {
21 + return;
22 + }
23 +
24 + var changeLines = new List<string>(pendingChanges.Count);
25 + foreach (var change in pendingChanges)
26 + {
27 + changeLines.Add($"- {GetSettingDisplayName(change.ConfigEntry)}: {FormatValue(change.ConfigEntry, change.PendingValue)}");
28 + }
29 +
30 + var contentText = string.Join(Environment.NewLine, changeLines);
31 +
32 + var contentPanel = new StackPanel { Spacing = 8 };
33 + contentPanel.Children.Add(new TextBlock
34 + {
35 + Text = "Settings_ApplyChangesDialogDescription".GetLocalized(),
36 + TextWrapping = TextWrapping.Wrap,
37 + });
38 + contentPanel.Children.Add(new TextBlock
39 + {
40 + Text = contentText,
41 + TextWrapping = TextWrapping.Wrap,
42 + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
43 + });
44 +
45 + var dialog = new ContentDialog
46 + {
47 + XamlRoot = xamlRoot,
48 + Title = "Settings_ApplyChangesDialogTitle".GetLocalized(),
49 + Content = contentPanel,
50 + PrimaryButtonText = "Settings_ApplyChangesDialogShutdownButton".GetLocalized(),
51 + SecondaryButtonText = "Settings_ApplyChangesDialogLaterButton".GetLocalized(),
52 + CloseButtonText = "Settings_ApplyChangesDialogCloseButton".GetLocalized(),
53 + DefaultButton = ContentDialogButton.Primary,
54 + };
55 +
56 + var result = await dialog.ShowAsync();
57 + if (result == ContentDialogResult.Primary)
58 + {
59 + // "Shutdown WSL now" — commit to disk and shutdown
60 + var commitResult = wslConfigService.CommitPendingChanges();
61 + if (commitResult != 0)
62 + {
63 + await ShowFailureDialogAsync(xamlRoot, string.Format("Settings_ApplyChangesDialogCommitFailed".GetLocalized(), commitResult));
64 + return;
65 + }
66 +
67 + try
68 + {
69 + var wslPath = Path.Combine(AppContext.BaseDirectory, "..", "wsl.exe");
70 + Process.Start(new ProcessStartInfo
71 + {
72 + FileName = wslPath,
73 + Arguments = "--shutdown",
74 + CreateNoWindow = true,
75 + UseShellExecute = false,
76 + })?.Dispose();
77 + }
78 + catch (Win32Exception ex)
79 + {
80 + await ShowFailureDialogAsync(xamlRoot, string.Format("Settings_ApplyChangesDialogShutdownFailed".GetLocalized(), ex.Message));
81 + }
82 + catch (InvalidOperationException ex)
83 + {
84 + await ShowFailureDialogAsync(xamlRoot, string.Format("Settings_ApplyChangesDialogShutdownFailed".GetLocalized(), ex.Message));
85 + }
86 + }
87 + else if (result == ContentDialogResult.Secondary)
88 + {
89 + // "Later" — commit to disk but don't shutdown; settings apply on next WSL restart
90 + var commitResult = wslConfigService.CommitPendingChanges();
91 + if (commitResult != 0)
92 + {
93 + await ShowFailureDialogAsync(xamlRoot, string.Format("Settings_ApplyChangesDialogCommitFailed".GetLocalized(), commitResult));
94 + }
95 + }
96 + // Esc / close — do nothing, leave changes pending
97 + }
98 +
99 + private static async Task ShowFailureDialogAsync(XamlRoot xamlRoot, string message)
100 + {
101 + var dialog = new ContentDialog
102 + {
103 + XamlRoot = xamlRoot,
104 + Title = "Settings_ApplyChangesDialogFailedTitle".GetLocalized(),
105 + Content = new TextBlock
106 + {
107 + Text = message,
108 + TextWrapping = TextWrapping.Wrap,
109 + MaxWidth = 620,
110 + },
111 + CloseButtonText = "Settings_ApplyChangesDialogCloseButton".GetLocalized(),
112 + DefaultButton = ContentDialogButton.Close,
113 + };
114 +
115 + await dialog.ShowAsync();
116 + }
117 +
118 + private static string GetSettingDisplayName(WslConfigEntry entry)
119 + {
120 + // Use existing Settings page resource keys so dialog matches page terminology
121 + if (SettingDisplayNameResources.TryGetValue(entry, out var resourceKey))
122 + {
123 + var localized = resourceKey.GetLocalized();
124 + if (!string.IsNullOrEmpty(localized) && localized != resourceKey)
125 + {
126 + return localized;
127 + }
128 + }
129 +
130 + // Fallback: type name (keeps something useful even if resx missing)
131 + return entry.ToString();
132 + }
133 +
134 + private static readonly IReadOnlyDictionary<WslConfigEntry, string> SettingDisplayNameResources =
135 + new Dictionary<WslConfigEntry, string>
136 + {
137 + // ResourceLoader.GetString() requires '/' (not '.') as the separator for x:Uid property resources
138 + { WslConfigEntry.ProcessorCount, "Settings_ProcCount/Header" },
139 + { WslConfigEntry.MemorySizeBytes, "Settings_MemorySize/Header" },
140 + { WslConfigEntry.SwapSizeBytes, "Settings_SwapSize/Header" },
141 + { WslConfigEntry.SwapFilePath, "Settings_SwapFilePath/Header" },
142 + { WslConfigEntry.VhdSizeBytes, "Settings_DefaultVHDSize/Header" },
143 + { WslConfigEntry.NetworkingMode, "Settings_NetworkingMode/Header" },
144 + { WslConfigEntry.FirewallEnabled, "Settings_HyperVFirewall/Header" },
145 + { WslConfigEntry.IgnoredPorts, "Settings_IgnoredPorts/Header" },
146 + { WslConfigEntry.LocalhostForwardingEnabled, "Settings_LocalhostForwarding/Header" },
147 + { WslConfigEntry.HostAddressLoopbackEnabled, "Settings_HostAddressLoopback/Header" },
148 + { WslConfigEntry.AutoProxyEnabled, "Settings_AutoProxy/Header" },
149 + { WslConfigEntry.InitialAutoProxyTimeout, "Settings_InitialAutoProxyTimeout/Header" },
150 + { WslConfigEntry.DNSProxyEnabled, "Settings_DNSProxy/Header" },
151 + { WslConfigEntry.DNSTunnelingEnabled, "Settings_DNSTunneling/Header" },
152 + { WslConfigEntry.BestEffortDNSParsingEnabled, "Settings_BestEffortDNS/Header" },
153 + { WslConfigEntry.AutoMemoryReclaim, "Settings_AutoMemoryReclaim/Header" },
154 + { WslConfigEntry.GUIApplicationsEnabled, "Settings_GUIApplications/Header" },
155 + { WslConfigEntry.NestedVirtualizationEnabled, "Settings_NestedVirtualization/Header" },
156 + { WslConfigEntry.SafeModeEnabled, "Settings_SafeMode/Header" },
157 + { WslConfigEntry.SparseVHDEnabled, "Settings_SparseVHD/Header" },
158 + { WslConfigEntry.VMIdleTimeout, "Settings_VMIdleTimeout/Header" },
159 + { WslConfigEntry.DebugConsoleEnabled, "Settings_DebugConsole/Header" },
160 + { WslConfigEntry.HardwarePerformanceCountersEnabled, "Settings_HWPerfCounters/Header" },
161 + { WslConfigEntry.KernelPath, "Settings_CustomKernelPath/Header" },
162 + { WslConfigEntry.SystemDistroPath, "Settings_CustomSystemDistroPath/Header" },
163 + { WslConfigEntry.KernelModulesPath, "Settings_CustomKernelModulesPath/Header" },
164 + };
165 +
166 + private static string FormatValue(WslConfigEntry entry, object value)
167 + {
168 + switch (entry.GetValueKind())
169 + {
170 + case WslConfigValueKind.UInt64:
171 + return string.Format("Settings_MegabyteStringFormat".GetLocalized(), (ulong)value / Constants.MB);
172 + case WslConfigValueKind.Int32:
173 + switch (entry)
174 + {
175 + case WslConfigEntry.InitialAutoProxyTimeout:
176 + case WslConfigEntry.VMIdleTimeout:
177 + return string.Format("Settings_MillisecondsStringFormat".GetLocalized(), (int)value);
178 + default:
179 + return ((int)value).ToString();
180 + }
181 + case WslConfigValueKind.String:
182 + return (string?)value ?? string.Empty;
183 + case WslConfigValueKind.NetworkingConfiguration:
184 + return FormatEnum((NetworkingConfiguration)value);
185 + case WslConfigValueKind.MemoryReclaimMode:
186 + return FormatEnum((MemoryReclaimMode)value);
187 + default:
188 + return FormatBool((bool)value);
189 + }
190 + }
191 +
192 + private static string FormatBool(bool value)
193 + {
194 + var localized = value
195 + ? "Settings_BooleanTrueText".GetLocalized()
196 + : "Settings_BooleanFalseText".GetLocalized();
197 + return string.IsNullOrEmpty(localized)
198 + ? (value ? bool.TrueString : bool.FalseString)
199 + : localized;
200 + }
201 +
202 + private static string FormatEnum<TEnum>(TEnum value) where TEnum : struct, Enum
203 + {
204 + // Try resource lookup first using the pattern "Settings_{EnumTypeName}_{EnumValue}".
205 + // GetLocalized throws COMException when the key doesn't exist, so fall back to the raw name.
206 + try
207 + {
208 + var resourceKey = $"Settings_{typeof(TEnum).Name}_{value}";
209 + var localized = resourceKey.GetLocalized();
210 + if (!string.IsNullOrEmpty(localized) && localized != resourceKey)
211 + {
212 + return localized;
213 + }
214 + }
215 + catch (COMException)
216 + {
217 + }
218 +
219 + return value.ToString();
220 + }
221 +}
src/windows/wslsettings/Views/Settings/ShellPage.xaml
+7
@@ -162,7 +162,14 @@
162 </behaviors:NavigationViewHeaderBehavior>
163 </i:Interaction.Behaviors>
164 <Grid>
165 + <Grid.RowDefinitions>
166 + <RowDefinition Height="*" />
167 + <RowDefinition Height="Auto" />
168 + </Grid.RowDefinitions>
169 <Frame x:Name="NavigationFrame" />
170 + <Button x:Uid="Settings_ApplyChangesButton" Grid.Row="1" Style="{StaticResource AccentButtonStyle}" HorizontalAlignment="Right" Margin="0,8,16,8"
171 + Visibility="{x:Bind HasPendingChanges, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"
172 + Click="ApplyChanges_Click"/>
173 </Grid>
174 </NavigationView>
175 </Grid>
src/windows/wslsettings/Views/Settings/ShellPage.xaml.cs
+27 -1
@@ -8,16 +8,27 @@ using System.Diagnostics;
8
9 using Windows.System;
10
11 +using System.ComponentModel;
12 +using System.Runtime.CompilerServices;
13 using WslSettings.Contracts.Services;
14 using WslSettings.Services;
15 using WslSettings.ViewModels;
16
17 namespace WslSettings.Views.Settings;
18
17 -public sealed partial class ShellPage : Page
19 +public sealed partial class ShellPage : Page, INotifyPropertyChanged
20 {
21 private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
22
23 + public bool HasPendingChanges => App.GetService<IWslConfigService>().HasPendingChanges;
24 +
25 + public event PropertyChangedEventHandler? PropertyChanged;
26 +
27 + private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
28 + {
29 + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
30 + }
31 +
32 private void RegisterNavigationService()
33 {
34 ViewModel.NavigationViewService.UnregisterEvents();
@@ -45,6 +56,21 @@ public sealed partial class ShellPage : Page
56 App.MainWindow.Activated += MainWindow_Activated;
57 AppTitleBarText.Text = "Settings_AppDisplayName".GetLocalized();
58 NavigationFrame.LostFocus += NavigationFrame_LostFocus;
59 +
60 + App.GetService<IWslConfigService>().PendingChangesChanged += OnPendingChangesChanged;
61 + }
62 +
63 + private void OnPendingChangesChanged()
64 + {
65 + _dispatcherQueue.TryEnqueue(() =>
66 + {
67 + OnPropertyChanged(nameof(HasPendingChanges));
68 + });
69 + }
70 +
71 + private async void ApplyChanges_Click(object sender, RoutedEventArgs e)
72 + {
73 + await SettingsApplyHelper.ShowApplyChangesDialogAsync(XamlRoot);
74 }
75
76 private void OnLoaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)