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)