[RegPreview] Various improvements on how files are saved (#37628)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

### This PR implements various fixes and improvements into
RegistryPreview for saving files:
1. Adds an unsaved file indicator in the title bar like in Notepad. (As
indicator we show the * character before the file title.)
2. The save button behaves like a "save as" button, if the file does not
exist on disk like in Notepad. (Without fix when running as non-admin
you get an access denied error message.)
3. If the app gets closed without saving and the file does not exist on
disk, then the user is now asked for the path. (Fixes crash on clicking
save button while closing the app.)
4. Failed save actions are handled now correctly on dirty closing,
opening files and all other actions that require saving the current
state. They will stop the process.
5. A fix for an incorrect enabled state of the save button after
opening, reloading and saving a file.
6. Reuse file name on save as button, if known. Otherwise use "new file"
template name like in Notepad.
7. Fix an app crash if you click the window's close button a second time
while the "Should save?" dialog is opened.
8. Added an reload dialog in case of unsaved changes.


![image](https://github.com/user-attachments/assets/9045446e-e9a3-4b81-8aa0-515b0821a969)


![image](https://github.com/user-attachments/assets/0888fbd2-851b-4101-a177-be9a3675b5ae)


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] **Closes:** #36876, #36875
- [x] **Communication:** I've discussed this with core contributors
already. If work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end user facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **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)
- [ ] **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

Local test build.

---------

Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com>
This commit is contained in:
Heiko 2025-06-17 05:35:35 +02:00 committed by GitHub
parent 82e9d42e02
commit b2d7182dcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 267 additions and 92 deletions

View File

@ -22,6 +22,10 @@ namespace RegistryPreviewUILib
{
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
// Indicator if we loaded/reloaded/saved a file and need to skip TextChanged event one time.
// (Solves the problem that enabling the event handler fires it one time.)
private static bool editorContentChangedScripted;
/// <summary>
/// Event that is will prevent the app from closing if the "save file" flag is active
/// </summary>
@ -76,6 +80,67 @@ namespace RegistryPreviewUILib
MonacoEditor.Focus(FocusState.Programmatic);
}
/// <summary>
/// New button action: Ask to save last changes and reset editor content to reg header only
/// </summary>
private async void NewButton_Click(object sender, RoutedEventArgs e)
{
// Check to see if the current file has been saved
if (saveButton.IsEnabled)
{
ContentDialog contentDialog = new ContentDialog()
{
Title = resourceLoader.GetString("YesNoCancelDialogTitle"),
Content = resourceLoader.GetString("YesNoCancelDialogContent"),
PrimaryButtonText = resourceLoader.GetString("YesNoCancelDialogPrimaryButtonText"),
SecondaryButtonText = resourceLoader.GetString("YesNoCancelDialogSecondaryButtonText"),
CloseButtonText = resourceLoader.GetString("YesNoCancelDialogCloseButtonText"),
DefaultButton = ContentDialogButton.Primary,
};
// Use this code to associate the dialog to the appropriate AppWindow by setting
// the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
{
contentDialog.XamlRoot = this.Content.XamlRoot;
}
ContentDialogResult contentDialogResult = await contentDialog.ShowAsync();
switch (contentDialogResult)
{
case ContentDialogResult.Primary:
// Save, then continue the new action
if (!AskFileName(string.Empty) ||
!SaveFile())
{
return;
}
break;
case ContentDialogResult.Secondary:
// Don't save and continue the new action!
break;
default:
// Don't open the new action!
return;
}
}
// mute the TextChanged handler to make for clean UI
MonacoEditor.TextChanged -= MonacoEditor_TextChanged;
// reset editor, file info and ui.
_appFileName = string.Empty;
ResetEditorAndFile();
// disable buttons that do not make sense
UpdateUnsavedFileState(false);
refreshButton.IsEnabled = false;
// restore the TextChanged handler
ButtonAction_RestoreTextChangedEvent();
}
/// <summary>
/// Uses a picker to select a new file to open
/// </summary>
@ -106,11 +171,15 @@ namespace RegistryPreviewUILib
{
case ContentDialogResult.Primary:
// Save, then continue the file open
SaveFile();
if (!AskFileName(string.Empty) ||
!SaveFile())
{
return;
}
break;
case ContentDialogResult.Secondary:
// Don't save and continue the file open!
saveButton.IsEnabled = false;
break;
default:
// Don't open the new file!
@ -137,14 +206,16 @@ namespace RegistryPreviewUILib
{
// mute the TextChanged handler to make for clean UI
MonacoEditor.TextChanged -= MonacoEditor_TextChanged;
// update file name
_appFileName = storageFile.Path;
UpdateToolBarAndUI(await OpenRegistryFile(_appFileName));
// disable the Save button as it's a new file
saveButton.IsEnabled = false;
UpdateUnsavedFileState(false);
// Restore the event handler as we're loaded
MonacoEditor.TextChanged += MonacoEditor_TextChanged;
ButtonAction_RestoreTextChangedEvent();
}
}
@ -153,7 +224,14 @@ namespace RegistryPreviewUILib
/// </summary>
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
SaveFile();
if (!AskFileName(string.Empty))
{
return;
}
// save and update window title
// error handling and ui update happens in SaveFile() method
_ = SaveFile();
}
/// <summary>
@ -161,47 +239,24 @@ namespace RegistryPreviewUILib
/// </summary>
private async void SaveAsButton_Click(object sender, RoutedEventArgs e)
{
// Save out a new REG file and then open it - we have to use the direct Win32 method because FileOpenPicker crashes when it's
// called while running as admin
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(_mainWindow);
string filename = SaveFilePicker.ShowDialog(
windowHandle,
resourceLoader.GetString("SuggestFileName"),
resourceLoader.GetString("FilterRegistryName") + '\0' + "*.reg" + '\0' + resourceLoader.GetString("FilterAllFiles") + '\0' + "*.*" + '\0' + '\0',
resourceLoader.GetString("SaveDialogTitle"));
// mute the TextChanged handler to make for clean UI
MonacoEditor.TextChanged -= MonacoEditor_TextChanged;
if (filename == string.Empty)
if (!AskFileName(_appFileName) || !SaveFile())
{
return;
}
_appFileName = filename;
SaveFile();
UpdateToolBarAndUI(await OpenRegistryFile(_appFileName));
// restore the TextChanged handler
ButtonAction_RestoreTextChangedEvent();
}
/// <summary>
/// Reloads the current REG file from storage
/// </summary>
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
{
// mute the TextChanged handler to make for clean UI
MonacoEditor.TextChanged -= MonacoEditor_TextChanged;
// reload the current Registry file and update the toolbar accordingly.
UpdateToolBarAndUI(await OpenRegistryFile(_appFileName), true, true);
// disable the Save button as it's a new file
saveButton.IsEnabled = false;
// restore the TextChanged handler
MonacoEditor.TextChanged += MonacoEditor_TextChanged;
}
/// <summary>
/// Resets the editor content
/// </summary>
private async void NewButton_Click(object sender, RoutedEventArgs e)
{
// Check to see if the current file has been saved
if (saveButton.IsEnabled)
@ -209,10 +264,9 @@ namespace RegistryPreviewUILib
ContentDialog contentDialog = new ContentDialog()
{
Title = resourceLoader.GetString("YesNoCancelDialogTitle"),
Content = resourceLoader.GetString("YesNoCancelDialogContent"),
PrimaryButtonText = resourceLoader.GetString("YesNoCancelDialogPrimaryButtonText"),
SecondaryButtonText = resourceLoader.GetString("YesNoCancelDialogSecondaryButtonText"),
CloseButtonText = resourceLoader.GetString("YesNoCancelDialogCloseButtonText"),
Content = resourceLoader.GetString("ReloadDialogContent"),
PrimaryButtonText = resourceLoader.GetString("ReloadDialogPrimaryButtonText"),
CloseButtonText = resourceLoader.GetString("ReloadDialogCloseButtonText"),
DefaultButton = ContentDialogButton.Primary,
};
@ -227,15 +281,10 @@ namespace RegistryPreviewUILib
switch (contentDialogResult)
{
case ContentDialogResult.Primary:
// Save, then continue the file open
SaveFile();
break;
case ContentDialogResult.Secondary:
// Don't save and continue the file open!
saveButton.IsEnabled = false;
// Don't save and continue the reload action!
break;
default:
// Don't open the new file!
// Don't continue the reload action!
return;
}
}
@ -243,16 +292,14 @@ namespace RegistryPreviewUILib
// mute the TextChanged handler to make for clean UI
MonacoEditor.TextChanged -= MonacoEditor_TextChanged;
// reset editor, file info and ui.
_appFileName = string.Empty;
ResetEditorAndFile();
// reload the current Registry file and update the toolbar accordingly.
UpdateToolBarAndUI(await OpenRegistryFile(_appFileName), true, true);
// disable the Save button as it's a new file
UpdateUnsavedFileState(false);
// restore the TextChanged handler
MonacoEditor.TextChanged += MonacoEditor_TextChanged;
// disable buttons that do not make sense
saveButton.IsEnabled = false;
refreshButton.IsEnabled = false;
ButtonAction_RestoreTextChangedEvent();
}
/// <summary>
@ -313,15 +360,20 @@ namespace RegistryPreviewUILib
switch (contentDialogResult)
{
case ContentDialogResult.Primary:
// Save, then continue the file open
SaveFile();
// Save, then continue the merge action
if (!AskFileName(string.Empty) ||
!SaveFile())
{
return;
}
break;
case ContentDialogResult.Secondary:
// Don't save and continue the file open!
saveButton.IsEnabled = false;
// Don't save and continue the merge action!
UpdateUnsavedFileState(false);
break;
default:
// Don't open the new file!
// Don't merge the file!
return;
}
}
@ -411,10 +463,29 @@ namespace RegistryPreviewUILib
_dispatcherQueue.TryEnqueue(() =>
{
RefreshRegistryFile();
saveButton.IsEnabled = true;
if (!editorContentChangedScripted)
{
UpdateUnsavedFileState(true);
}
editorContentChangedScripted = false;
});
}
/// <summary>
/// Sets indicator for programatic text change and adds text changed handler
/// </summary>
/// <remarks>
/// Use this always, if button actions temporary disable the text changed event
/// </remarks>
private void ButtonAction_RestoreTextChangedEvent()
{
// Solves the problem that enabling the event handler fires it one time.
// These one time fired event would causes wrong unsaved changes state.
editorContentChangedScripted = true;
MonacoEditor.TextChanged += MonacoEditor_TextChanged;
}
// Commands to show data preview
public void ButtonExtendedPreview_Click(object sender, RoutedEventArgs e)
{

View File

@ -11,6 +11,7 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.UI.Input;
@ -24,7 +25,10 @@ namespace RegistryPreviewUILib
{
private const string NEWFILEHEADER = "Windows Registry Editor Version 5.00\r\n\r\n";
private static readonly string _unsavedFileIndicator = "* ";
private static readonly char[] _unsavedFileIndicatorChars = [' ', '*'];
private static SemaphoreSlim _dialogSemaphore = new(1);
private string lastKeyPath;
public delegate void UpdateWindowTitleFunction(string title);
@ -832,42 +836,66 @@ namespace RegistryPreviewUILib
/// </summary>
private async void HandleDirtyClosing(string title, string content, string primaryButtonText, string secondaryButtonText, string closeButtonText)
{
ContentDialog contentDialog = new ContentDialog()
if (_dialogSemaphore.CurrentCount == 0)
{
Title = title,
Content = content,
PrimaryButtonText = primaryButtonText,
SecondaryButtonText = secondaryButtonText,
CloseButtonText = closeButtonText,
DefaultButton = ContentDialogButton.Primary,
};
// Use this code to associate the dialog to the appropriate AppWindow by setting
// the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
{
contentDialog.XamlRoot = this.Content.XamlRoot;
return;
}
ContentDialogResult contentDialogResult = await contentDialog.ShowAsync();
switch (contentDialogResult)
try
{
case ContentDialogResult.Primary:
// Save, then close
SaveFile();
break;
case ContentDialogResult.Secondary:
// Don't save, and then close!
saveButton.IsEnabled = false;
break;
default:
// Cancel closing!
return;
}
await _dialogSemaphore.WaitAsync();
// if we got here, we should try to close again
Application.Current.Exit();
ContentDialog contentDialog = new ContentDialog()
{
Title = title,
Content = content,
PrimaryButtonText = primaryButtonText,
SecondaryButtonText = secondaryButtonText,
CloseButtonText = closeButtonText,
DefaultButton = ContentDialogButton.Primary,
};
// Use this code to associate the dialog to the appropriate AppWindow by setting
// the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow.
if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8))
{
contentDialog.XamlRoot = this.Content.XamlRoot;
}
ContentDialogResult contentDialogResult = await contentDialog.ShowAsync();
switch (contentDialogResult)
{
case ContentDialogResult.Primary:
// Save, then close
if (!AskFileName(string.Empty) ||
!SaveFile())
{
return;
}
break;
case ContentDialogResult.Secondary:
// Don't save, and then close!
UpdateUnsavedFileState(false);
break;
default:
// Cancel closing!
return;
}
// if we got here, we should try to close again
Application.Current.Exit();
}
catch
{
// Normally nothing to catch here.
// But for safety the try-catch ensures that we always release the content dialog lock and exit correctly.
}
finally
{
_dialogSemaphore.Release();
}
}
/// <summary>
@ -927,11 +955,71 @@ namespace RegistryPreviewUILib
type.InvokeMember("ProtectedCursor", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.SetProperty | BindingFlags.Instance, null, uiElement, new object[] { cursor }, CultureInfo.InvariantCulture);
}
public void UpdateUnsavedFileState(bool unsavedChanges)
{
// get, cut and analyze the current title
string currentTitle = Regex.Replace(_mainWindow.Title, APPNAME + @"$|\s-\s" + APPNAME + @"$", string.Empty);
bool titleContainsIndicator = currentTitle.StartsWith(_unsavedFileIndicator, StringComparison.CurrentCultureIgnoreCase);
// update window title and save button state
if (unsavedChanges)
{
saveButton.IsEnabled = true;
if (!titleContainsIndicator)
{
_updateWindowTitleFunction(_unsavedFileIndicator + currentTitle);
}
}
else
{
saveButton.IsEnabled = false;
if (titleContainsIndicator)
{
_updateWindowTitleFunction(currentTitle.TrimStart(_unsavedFileIndicatorChars));
}
}
}
/// <summary>
/// Ask the user for the file path if it is unknown because of an unsaved file
/// </summary>
/// <param name="fileName">If not empty always ask for a file path and use the value as name.</param>
/// <returns>Returns true if user selected a path, otherwise false</returns>
public bool AskFileName(string fileName)
{
if (string.IsNullOrEmpty(_appFileName) || !string.IsNullOrEmpty(fileName) )
{
string fName = string.IsNullOrEmpty(fileName) ? resourceLoader.GetString("SuggestFileName") : fileName;
// Save out a new REG file and then open it - we have to use the direct Win32 method because FileOpenPicker crashes when it's
// called while running as admin
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(_mainWindow);
string filename = SaveFilePicker.ShowDialog(
windowHandle,
fName,
resourceLoader.GetString("FilterRegistryName") + '\0' + "*.reg" + '\0' + resourceLoader.GetString("FilterAllFiles") + '\0' + "*.*" + '\0' + '\0',
resourceLoader.GetString("SaveDialogTitle"));
if (filename == string.Empty)
{
return false;
}
_appFileName = filename;
}
return true;
}
/// <summary>
/// Wrapper method that saves the current file in place, using the current text in editor.
/// </summary>
private void SaveFile()
private bool SaveFile()
{
bool saveSuccess = true;
ChangeCursor(gridPreview, true);
// set up the FileStream for all writing
@ -955,10 +1043,13 @@ namespace RegistryPreviewUILib
streamWriter.Close();
// only change when the save is successful
saveButton.IsEnabled = false;
UpdateUnsavedFileState(false);
_updateWindowTitleFunction(_appFileName);
}
catch (UnauthorizedAccessException ex)
{
saveSuccess = false;
// this exception is thrown if the file is there but marked as read only
ShowMessageBox(
resourceLoader.GetString("ErrorDialogTitle"),
@ -967,6 +1058,8 @@ namespace RegistryPreviewUILib
}
catch
{
saveSuccess = false;
// this catch handles all other exceptions thrown when trying to write the file out
ShowMessageBox(
resourceLoader.GetString("ErrorDialogTitle"),
@ -984,6 +1077,8 @@ namespace RegistryPreviewUILib
// restore the cursor
ChangeCursor(gridPreview, false);
return saveSuccess;
}
/// <summary>

View File

@ -258,12 +258,18 @@
<data name="YesNoCancelDialogCloseButtonText" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="ReloadDialogCloseButtonText" xml:space="preserve">
<value>No</value>
</data>
<data name="YesNoCancelDialogContent" xml:space="preserve">
<value>Save changes?</value>
</data>
<data name="YesNoCancelDialogPrimaryButtonText" xml:space="preserve">
<value>Save</value>
</data>
<data name="ReloadDialogPrimaryButtonText" xml:space="preserve">
<value>Yes</value>
</data>
<data name="YesNoCancelDialogSecondaryButtonText" xml:space="preserve">
<value>Don't save</value>
</data>
@ -363,4 +369,7 @@
<data name="NewButton.Label" xml:space="preserve">
<value>New</value>
</data>
<data name="ReloadDialogContent" xml:space="preserve">
<value>You lose any unsaved changes. Reload anyway?</value>
</data>
</root>