diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b0f34f198..198d77a3c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -240,6 +240,7 @@ jobs: msbuild $env:SOLUTION_NAME ` /p:Platform=$env:PLATFORM ` /p:Configuration=$env:CONFIGURATION ` + /p:AllowUnsafeBlocks=true ` /p:UapAppxPackageBuildMode=$env:UAP_APPX_PACKAGE_BUILD_MODE ` /p:AppxBundle=$env:APPX_BUNDLE ` /p:AppxPackageSigningEnabled=$env:APPX_PACKAGE_SIGNING_ENABLED ` diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 1667a62cf..71f77c2bb 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -25,6 +25,7 @@ steps: solution: '$(solution)' configuration: '$(buildConfiguration)' msbuildArgs: '/p:AppxBundlePlatforms="$(buildPlatform)" + /p:AllowUnsafeBlocks=true /p:AppxPackageDir="$(appxPackageDir)" /p:AppxBundle=Always /p:UapAppxPackageBuildMode=StoreUpload diff --git a/src/Notepads/Controls/TextEditor/ITextEditor.cs b/src/Notepads/Controls/TextEditor/ITextEditor.cs index 4b8feb04f..69e6d44dc 100644 --- a/src/Notepads/Controls/TextEditor/ITextEditor.cs +++ b/src/Notepads/Controls/TextEditor/ITextEditor.cs @@ -29,6 +29,7 @@ public interface ITextEditor event EventHandler FileSaved; event EventHandler FileReloaded; event EventHandler FileRenamed; + event EventHandler FileAttributeChanged; Guid Id { get; set; } @@ -46,6 +47,8 @@ public interface ITextEditor string EditingFilePath { get; } + bool IsReadOnly { get; set; } + StorageFile EditingFile { get; } bool IsModified { get; } diff --git a/src/Notepads/Controls/TextEditor/TextEditor.xaml.cs b/src/Notepads/Controls/TextEditor/TextEditor.xaml.cs index b85923151..2edeb9c1f 100644 --- a/src/Notepads/Controls/TextEditor/TextEditor.xaml.cs +++ b/src/Notepads/Controls/TextEditor/TextEditor.xaml.cs @@ -54,6 +54,7 @@ public sealed partial class TextEditor : ITextEditor, IDisposable public event EventHandler FileSaved; public event EventHandler FileReloaded; public event EventHandler FileRenamed; + public event EventHandler FileAttributeChanged; public Guid Id { get; set; } @@ -73,6 +74,24 @@ public sealed partial class TextEditor : ITextEditor, IDisposable public string EditingFilePath { get; private set; } + private System.IO.FileAttributes _fileAttributes; + + public bool IsReadOnly + { + get => _fileAttributes.HasFlag(System.IO.FileAttributes.ReadOnly); + set + { + if (EditingFile == null) return; + + EditingFile.SetFileAttributes( + value + ? _fileAttributes | System.IO.FileAttributes.ReadOnly + : _fileAttributes & ~System.IO.FileAttributes.ReadOnly, + () => CheckAndUpdateAttributesInfo() + ); + } + } + private StorageFile _editingFile; public StorageFile EditingFile @@ -85,7 +104,7 @@ private set } } - private void UpdateDocumentInfo() + private async void UpdateDocumentInfo() { if (EditingFile == null) { @@ -100,6 +119,8 @@ private void UpdateDocumentInfo() FileType = FileTypeUtility.GetFileTypeByFileName(EditingFile.Name); } + await CheckAndUpdateAttributesInfo(); + // Hide content preview if current file type is not supported for previewing if (!FileTypeUtility.IsPreviewSupported(FileType)) { @@ -110,6 +131,20 @@ private void UpdateDocumentInfo() } } + private async Task CheckAndUpdateAttributesInfo() + { + if (EditingFile == null || FileModificationState == FileModificationState.RenamedMovedOrDeleted) return; + + var fileAttributes = EditingFile.GetFileAttributes(!_isAttributeCheckedOnce); + _isAttributeCheckedOnce = true; + if (_fileAttributes == fileAttributes) return; + + _fileAttributes = fileAttributes; + await Dispatcher.CallOnUIThreadAsync( + () => FileAttributeChanged?.Invoke(this, null) + ); + } + private bool _isModified; public bool IsModified @@ -142,6 +177,8 @@ private set private FileModificationState _fileModificationState; + private bool _isAttributeCheckedOnce = false; + private bool _isContentPreviewPanelOpened; private readonly ResourceLoader _resourceLoader = ResourceLoader.GetForCurrentView(); @@ -351,10 +388,12 @@ public TextEditorStateMetaData GetTextEditorStateMetaData() return metaData; } - private void TextEditor_Loaded(object sender, RoutedEventArgs e) + private async void TextEditor_Loaded(object sender, RoutedEventArgs e) { Loaded?.Invoke(this, e); + await CheckAndUpdateAttributesInfo(); + StartCheckingFileStatusPeriodically(); // Insert "Legacy Windows Notepad" style date and time if document starts with ".LOG" @@ -442,6 +481,8 @@ await Dispatcher.CallOnUIThreadAsync(() => FileModificationState = newState.Value; }); + await CheckAndUpdateAttributesInfo(); + _fileStatusSemaphoreSlim.Release(); } diff --git a/src/Notepads/Core/NotepadsCore.cs b/src/Notepads/Core/NotepadsCore.cs index 00a81bd3f..322185a3c 100644 --- a/src/Notepads/Core/NotepadsCore.cs +++ b/src/Notepads/Core/NotepadsCore.cs @@ -82,9 +82,25 @@ public NotepadsCore(SetsView sets, _dispatcher = dispatcher; _extensionProvider = extensionProvider; + ThemeSettingsService.OnThemeChanged += ThemeSettingsService_OnThemeChanged; ThemeSettingsService.OnAccentColorChanged += ThemeSettingsService_OnAccentColorChanged; } + private async void ThemeSettingsService_OnThemeChanged(object sender, ElementTheme e) + { + await _dispatcher.CallOnUIThreadAsync(() => + { + if (Sets.Items == null) return; + foreach (SetsViewItem item in Sets.Items) + { + var textEditor = item.Content as ITextEditor; + item.Icon.Foreground = textEditor != null && textEditor.IsReadOnly + ? ThemeSettingsService.GetReadOnlyTabIconForegroundBrush() + : new SolidColorBrush(ThemeSettingsService.AppAccentColor); + } + }); + } + private async void ThemeSettingsService_OnAccentColorChanged(object sender, Color color) { await _dispatcher.CallOnUIThreadAsync(() => @@ -92,7 +108,10 @@ await _dispatcher.CallOnUIThreadAsync(() => if (Sets.Items == null) return; foreach (SetsViewItem item in Sets.Items) { - item.Icon.Foreground = new SolidColorBrush(color); + var textEditor = item.Content as ITextEditor; + item.Icon.Foreground = textEditor != null && textEditor.IsReadOnly + ? ThemeSettingsService.GetReadOnlyTabIconForegroundBrush() + : new SolidColorBrush(color); item.SelectionIndicatorForeground = new SolidColorBrush(color); } }); @@ -203,6 +222,7 @@ public ITextEditor CreateTextEditor( textEditor.LineEndingChanged += TextEditor_OnLineEndingChanged; textEditor.EncodingChanged += TextEditor_OnEncodingChanged; textEditor.FileRenamed += TextEditor_OnFileRenamed; + textEditor.FileAttributeChanged += TextEditor_OnFileAttributeChanged; return textEditor; } @@ -241,6 +261,7 @@ public void DeleteTextEditor(ITextEditor textEditor) textEditor.LineEndingChanged -= TextEditor_OnLineEndingChanged; textEditor.EncodingChanged -= TextEditor_OnEncodingChanged; textEditor.FileRenamed -= TextEditor_OnFileRenamed; + textEditor.FileAttributeChanged -= TextEditor_OnFileAttributeChanged; textEditor.Dispose(); } @@ -405,7 +426,9 @@ private SetsViewItem CreateTextEditorSetsViewItem(ITextEditor textEditor) FontSize = 1.5, Width = 3, Height = 3, - Foreground = new SolidColorBrush(ThemeSettingsService.AppAccentColor), + Foreground = textEditor.IsReadOnly + ? ThemeSettingsService.GetReadOnlyTabIconForegroundBrush() + : new SolidColorBrush(ThemeSettingsService.AppAccentColor) }; var textEditorSetsViewItem = new SetsViewItem @@ -570,6 +593,20 @@ private void TextEditor_OnFileRenamed(object sender, EventArgs e) TextEditorRenamed?.Invoke(this, textEditor); } + private void TextEditor_OnFileAttributeChanged(object sender, EventArgs e) + { + if (!(sender is ITextEditor textEditor)) return; + + var item = GetTextEditorSetsViewItem(textEditor); + if (item == null) return; + item.Icon.Foreground = textEditor.IsReadOnly + ? ThemeSettingsService.GetReadOnlyTabIconForegroundBrush() + : new SolidColorBrush(ThemeSettingsService.AppAccentColor); + + TextEditorFileModificationStateChanged?.Invoke(this, textEditor); + TextEditorEditorModificationStateChanged?.Invoke(this, textEditor); + } + #region DragAndDrop private async void Sets_DragOver(object sender, DragEventArgs args) diff --git a/src/Notepads/Core/TabContextFlyout.cs b/src/Notepads/Core/TabContextFlyout.cs index 1c3a61917..5bb8a0802 100644 --- a/src/Notepads/Core/TabContextFlyout.cs +++ b/src/Notepads/Core/TabContextFlyout.cs @@ -13,6 +13,7 @@ using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Input; + using Windows.UI.Xaml.Media; public class TabContextFlyout : MenuFlyout { @@ -23,6 +24,7 @@ public class TabContextFlyout : MenuFlyout private MenuFlyoutItem _copyFullPath; private MenuFlyoutItem _openContainingFolder; private MenuFlyoutItem _rename; + private MenuFlyoutItem _toggleReadOnly; private string _filePath; private string _containingFolderPath; @@ -46,6 +48,7 @@ public TabContextFlyout(INotepadsCore notepadsCore, ITextEditor textEditor) Items.Add(OpenContainingFolder); Items.Add(new MenuFlyoutSeparator()); Items.Add(Rename); + Items.Add(ToggleReadOnly); var style = new Style(typeof(MenuFlyoutPresenter)); style.Setters.Add(new Setter(Control.BorderThicknessProperty, 0)); @@ -69,12 +72,21 @@ private void TabContextFlyout_Opening(object sender, object e) _filePath = _textEditor.EditingFile.Path; _containingFolderPath = Path.GetDirectoryName(_filePath); isFileReadonly = FileSystemUtility.IsFileReadOnly(_textEditor.EditingFile); + ToggleReadOnly.Visibility = Visibility.Visible; + } + else + { + ToggleReadOnly.Visibility = Visibility.Collapsed; } CloseOthers.IsEnabled = CloseRight.IsEnabled = _notepadsCore.GetNumberOfOpenedTextEditors() > 1; CopyFullPath.IsEnabled = !string.IsNullOrEmpty(_filePath); OpenContainingFolder.IsEnabled = !string.IsNullOrEmpty(_containingFolderPath); Rename.IsEnabled = _textEditor.FileModificationState != FileModificationState.RenamedMovedOrDeleted && !isFileReadonly; + ToggleReadOnly.IsEnabled = _textEditor.FileModificationState == FileModificationState.Untouched; + ToggleReadOnly.Text = _textEditor.IsReadOnly + ? _resourceLoader.GetString("Tab_ContextFlyout_ToggleReadOnlyOffButtonDisplayText") + : _resourceLoader.GetString("Tab_ContextFlyout_ToggleReadOnlyOnButtonDisplayText"); if (App.IsGameBarWidget) { @@ -272,6 +284,28 @@ private MenuFlyoutItem Rename } } + private MenuFlyoutItem ToggleReadOnly + { + get + { + if (_toggleReadOnly == null) + { + _toggleReadOnly = new MenuFlyoutItem() { Text = _resourceLoader.GetString("Tab_ContextFlyout_ToggleReadOnlyOnButtonDisplayText") }; + _toggleReadOnly.Click += (sender, args) => + { + if (!(this.Target is Notepads.Controls.SetsViewItem item)) return; + + _textEditor.IsReadOnly = (_toggleReadOnly.Text == _resourceLoader.GetString("Tab_ContextFlyout_ToggleReadOnlyOnButtonDisplayText")); + + item.Icon.Foreground= _textEditor.IsReadOnly + ? ThemeSettingsService.GetReadOnlyTabIconForegroundBrush() + : new SolidColorBrush(ThemeSettingsService.AppAccentColor); + }; + } + return _toggleReadOnly; + } + } + private void ExecuteOnAllTextEditors(Action action) { foreach (ITextEditor textEditor in _notepadsCore.GetAllTextEditors()) diff --git a/src/Notepads/Notepads.csproj b/src/Notepads/Notepads.csproj index 279ad618a..75f333195 100644 --- a/src/Notepads/Notepads.csproj +++ b/src/Notepads/Notepads.csproj @@ -37,6 +37,7 @@ false prompt true + true bin\x86\Release\ @@ -48,6 +49,7 @@ false prompt true + true true @@ -61,6 +63,7 @@ false prompt true + true true @@ -73,6 +76,7 @@ false prompt true + true true @@ -85,6 +89,7 @@ false prompt true + true true @@ -97,6 +102,7 @@ false prompt true + true true @@ -110,6 +116,7 @@ prompt true false + true bin\x64\Release\ @@ -121,6 +128,7 @@ false prompt true + true true @@ -133,6 +141,7 @@ false prompt true + true true @@ -177,6 +186,7 @@ + AboutPage.xaml diff --git a/src/Notepads/Services/ThemeSettingsService.cs b/src/Notepads/Services/ThemeSettingsService.cs index c1a438b81..3e315f4a1 100644 --- a/src/Notepads/Services/ThemeSettingsService.cs +++ b/src/Notepads/Services/ThemeSettingsService.cs @@ -289,6 +289,16 @@ private static Brush GetAppBackgroundBrush(ElementTheme theme) } } + public static Brush GetReadOnlyTabIconForegroundBrush() + { + var darkModeBaseColor = Color.FromArgb(255, 123, 123, 123); + var lightModeBaseColor = Color.FromArgb(255, 146, 146, 146); + + var baseColor = ThemeMode == ElementTheme.Light ? lightModeBaseColor : darkModeBaseColor; + + return new SolidColorBrush(baseColor); + } + public static void ApplyThemeForTitleBarButtons(ApplicationViewTitleBar titleBar, ElementTheme theme) { if (theme == ElementTheme.Dark) diff --git a/src/Notepads/Strings/en-US/Resources.resw b/src/Notepads/Strings/en-US/Resources.resw index 3a480c07e..540516f74 100644 --- a/src/Notepads/Strings/en-US/Resources.resw +++ b/src/Notepads/Strings/en-US/Resources.resw @@ -693,4 +693,20 @@ File extension "{0}" is not supported at this moment FileRenameError: Extension is not currently supported. {0} stands for the file extension string. - + + Remove read-only flag + TextEditor: ContextFlyout "ToggleReadOnlyOff" button display text. + + + Make read-only + TextEditor: ContextFlyout "ToggleReadOnlyOn" button display text. + + + Read-only + TextEditor: PathIndicator "Toggle File Read-only Attribute" ToggleMenuFlyoutItem display text. + + + File is read-only, changes wouldn't be saved + TextEditor: FileIsReadOnlyIndicator tool tip display text. + + \ No newline at end of file diff --git a/src/Notepads/Utilities/FileSystemUtility.cs b/src/Notepads/Utilities/FileSystemUtility.cs index 833693334..e00fb073b 100644 --- a/src/Notepads/Utilities/FileSystemUtility.cs +++ b/src/Notepads/Utilities/FileSystemUtility.cs @@ -282,7 +282,7 @@ public static async Task GetDateModified(StorageFile file) return dateModified.ToFileTime(); } - public static bool IsFileReadOnly(StorageFile file) + public static bool IsFileReadOnly(IStorageFile file) { return (file.Attributes & Windows.Storage.FileAttributes.ReadOnly) != 0; } diff --git a/src/Notepads/Utilities/Win32FileSystemUtility.cs b/src/Notepads/Utilities/Win32FileSystemUtility.cs new file mode 100644 index 000000000..175346802 --- /dev/null +++ b/src/Notepads/Utilities/Win32FileSystemUtility.cs @@ -0,0 +1,208 @@ +namespace Notepads.Utilities +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.InteropServices; + using System.Runtime.CompilerServices; + using Microsoft.AppCenter.Crashes; + using Microsoft.AppCenter.Analytics; + using Microsoft.Toolkit.Uwp.Helpers; + using Microsoft.Win32.SafeHandles; + using Notepads.Services; + using Windows.System; + using System.Threading.Tasks; + + public static class Win32FileSystemUtility + { + private const string FileAttributeProperty = "System.FileAttributes"; + + [DllImport("api-ms-win-core-file-l2-1-0.dll", CharSet = CharSet.Auto, + CallingConvention = CallingConvention.StdCall, + SetLastError = true)] + public unsafe static extern bool GetFileInformationByHandleEx( + SafeFileHandle hFile, + FILE_INFO_BY_HANDLE_CLASS FileInformationClass, + byte* lpFileInformation, + uint dwBufferSize + ); + + [DllImport("api-ms-win-core-file-fromapp-l1-1-0.dll", CharSet = CharSet.Auto, + CallingConvention = CallingConvention.StdCall, + SetLastError = true)] + public static extern bool SetFileAttributesFromApp( + string lpFileName, + uint dwFileAttributes + ); + + public enum FILE_INFO_BY_HANDLE_CLASS + { + FileBasicInfo, + FileStandardInfo, + FileNameInfo, + FileRenameInfo, + FileDispositionInfo, + FileAllocationInfo, + FileEndOfFileInfo, + FileStreamInfo, + FileCompressionInfo, + FileAttributeTagInfo, + FileIdBothDirectoryInfo, + FileIdBothDirectoryRestartInfo, + FileIoPriorityHintInfo, + FileRemoteProtocolInfo, + FileFullDirectoryInfo, + FileFullDirectoryRestartInfo, + FileStorageInfo, + FileAlignmentInfo, + FileIdInfo, + FileIdExtdDirectoryInfo, + FileIdExtdDirectoryRestartInfo, + FileDispositionInfoEx, + FileRenameInfoEx, + FileCaseSensitiveInfo, + FileNormalizedNameInfo, + MaximumFileInfoByHandleClass + } + + [StructLayout(LayoutKind.Sequential)] + public unsafe struct FILE_BASIC_INFO + { + public LARGE_INTEGER CreationTime; + public LARGE_INTEGER LastAccessTime; + public LARGE_INTEGER LastWriteTime; + public LARGE_INTEGER ChangeTime; + public uint FileAttributes; + } + + [StructLayout(LayoutKind.Explicit, Size = 8)] + public unsafe struct LARGE_INTEGER + { + [FieldOffset(0)] public Int64 QuadPart; + [FieldOffset(0)] public UInt32 LowPart; + [FieldOffset(4)] public Int32 HighPart; + } + + public static FileAttributes GetFileAttributes( + this Windows.Storage.IStorageFile file, + bool logError = false + ) + { + FileAttributes fileAttributes = 0; + unsafe + { + var size = Marshal.SizeOf(); + var buff = new byte[size]; + fixed (byte* fileInformationBuff = buff) + { + ref var fileInformation = ref Unsafe.As(ref buff[0]); + SafeFileHandle hFile = null; + + try + { + hFile = file.CreateSafeFileHandle(FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (GetFileInformationByHandleEx( + hFile, + FILE_INFO_BY_HANDLE_CLASS.FileBasicInfo, + fileInformationBuff, + (uint)size + ) + ) + { + fileAttributes = (FileAttributes)fileInformation.FileAttributes; + } + else + { + if (logError) throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()); + } + } + catch (Exception e) + { + if (logError) + { + var diagnosticInfo = new Dictionary() + { + { "Message", e.Message }, + { "Exception", e.ToString() }, + { "Culture", SystemInformation.Culture.EnglishName }, + { "AvailableMemory", SystemInformation.AvailableMemory.ToString("F0") }, + { "OSArchitecture", SystemInformation.OperatingSystemArchitecture.ToString() }, + { "OSVersion", SystemInformation.OperatingSystemVersion.ToString() }, + { "FileType", file.FileType } + }; + + var attachment = ErrorAttachmentLog.AttachmentWithText( + $"Exception: {e}, " + + $"Message: {e.Message}, " + + $"InnerException: {e.InnerException}, " + + $"InnerExceptionMessage: {e.InnerException?.Message}", + "FileAttribuesFetchException"); + + Analytics.TrackEvent("OnFileAttribuesFetchException", diagnosticInfo); + Crashes.TrackError(e, diagnosticInfo, attachment); + } + } + finally + { + hFile?.Dispose(); + } + } + } + return fileAttributes; + } + + public static async void SetFileAttributes( + this Windows.Storage.StorageFile file, + FileAttributes fileAttributes, + Func completion + ) + { + try + { + await file.Properties.SavePropertiesAsync( + new List> + { + new KeyValuePair(FileAttributeProperty, (uint)fileAttributes) + } + ); + + await completion.Invoke(); + } + catch (Exception e) + { + if (!SetFileAttributesFromApp(file.Path, (uint)fileAttributes)) + { + e = Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error()); + DispatcherQueue.GetForCurrentThread().TryEnqueue( + () => NotificationCenter.Instance.PostNotification(e.Message, 1500) + ); + + var diagnosticInfo = new Dictionary() + { + { "Message", e.Message }, + { "Exception", e.ToString() }, + { "Culture", SystemInformation.Culture.EnglishName }, + { "AvailableMemory", SystemInformation.AvailableMemory.ToString("F0") }, + { "OSArchitecture", SystemInformation.OperatingSystemArchitecture.ToString() }, + { "OSVersion", SystemInformation.OperatingSystemVersion.ToString() }, + { "FileType", file.FileType } + }; + + var attachment = ErrorAttachmentLog.AttachmentWithText( + $"Exception: {e}, " + + $"Message: {e.Message}, " + + $"InnerException: {e.InnerException}, " + + $"InnerExceptionMessage: {e.InnerException?.Message}", + "FileAttribuesSetException"); + + Analytics.TrackEvent("OnFileAttribuesSetException", diagnosticInfo); + Crashes.TrackError(e, diagnosticInfo, attachment); + } + else + { + await completion.Invoke(); + } + } + } + } +} \ No newline at end of file diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs b/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs index 81d0e8b2f..9b85b850a 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs @@ -152,11 +152,11 @@ private async Task SaveInternal(ITextEditor textEditor, StorageFile file, bool r } } - private async Task Save(ITextEditor textEditor, bool saveAs, bool ignoreUnmodifiedDocument = false, bool rebuildOpenRecentItems = true) + private async Task Save(ITextEditor textEditor, bool saveAs, bool ignoreUnmodifiedDocument = false, bool rebuildOpenRecentItems = true, bool skipReadOnlyFile = false) { if (textEditor == null) return false; - if (ignoreUnmodifiedDocument && !textEditor.IsModified) + if ((ignoreUnmodifiedDocument && !textEditor.IsModified) || (skipReadOnlyFile && textEditor.IsReadOnly)) { return true; } diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.MainMenu.cs b/src/Notepads/Views/MainPage/NotepadsMainPage.MainMenu.cs index 1017edc6f..c331842da 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.MainMenu.cs +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.MainMenu.cs @@ -83,14 +83,14 @@ private void MainMenuButtonFlyout_Opening(object sender, object e) } else if (selectedTextEditor.IsEditorEnabled() == false) { - MenuSaveButton.IsEnabled = selectedTextEditor.IsModified; + MenuSaveButton.IsEnabled = selectedTextEditor.IsModified && !selectedTextEditor.IsReadOnly; MenuSaveAsButton.IsEnabled = true; MenuFindButton.IsEnabled = false; MenuReplaceButton.IsEnabled = false; } else { - MenuSaveButton.IsEnabled = selectedTextEditor.IsModified; + MenuSaveButton.IsEnabled = selectedTextEditor.IsModified && !selectedTextEditor.IsReadOnly; MenuSaveAsButton.IsEnabled = true; MenuFindButton.IsEnabled = true; MenuReplaceButton.IsEnabled = true; diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs b/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs index 56a08ae21..a6b8f6019 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs @@ -18,6 +18,7 @@ using Notepads.Extensions; using Notepads.Services; using Notepads.Utilities; + using Windows.UI.Xaml.Media; public sealed partial class NotepadsMainPage { @@ -77,8 +78,17 @@ private void UpdateFileModificationStateIndicator(ITextEditor textEditor) if (StatusBar == null) return; if (textEditor.FileModificationState == FileModificationState.Untouched) { - FileModificationStateIndicatorIcon.Glyph = ""; - FileModificationStateIndicator.Visibility = Visibility.Collapsed; + if (textEditor.IsReadOnly) + { + FileModificationStateIndicatorIcon.Glyph = "\uE72E"; // Lock Icon + ToolTipService.SetToolTip(FileModificationStateIndicator, _resourceLoader.GetString("TextEditor_FileIsReadOnly_ToolTip")); + FileModificationStateIndicator.Visibility = Visibility.Visible; + } + else + { + FileModificationStateIndicatorIcon.Glyph = ""; + FileModificationStateIndicator.Visibility = Visibility.Collapsed; + } } else if (textEditor.FileModificationState == FileModificationState.Modified) { @@ -121,6 +131,9 @@ private void UpdateEditorModificationIndicator(ITextEditor textEditor) ModificationIndicator.Text = _resourceLoader.GetString("TextEditor_ModificationIndicator_Text"); ModificationIndicator.Visibility = Visibility.Visible; ModificationIndicator.IsTapEnabled = true; + ModificationIndicator.Foreground = textEditor.IsReadOnly + ? ThemeSettingsService.GetReadOnlyTabIconForegroundBrush() + : Application.Current.Resources["SystemControlForegroundAccentBrush"] as SolidColorBrush; } else { @@ -225,6 +238,16 @@ private async void ReloadFileFromDisk(object sender, RoutedEventArgs e) } } + private void RemoveReadonlyFlag(object sender, RoutedEventArgs e) + { + var selectedEditor = NotepadsCore.GetSelectedTextEditor(); + + if (selectedEditor?.EditingFile != null && selectedEditor.IsReadOnly) + { + selectedEditor.IsReadOnly = false; + } + } + private void CopyFullPath(object sender, RoutedEventArgs e) { var selectedEditor = NotepadsCore.GetSelectedTextEditor(); @@ -266,6 +289,15 @@ private async void RenameFileAsync(object sender, RoutedEventArgs e) await RenameFileAsync(selectedEditor); } + private void ToggleFileReadonlyAttribute(object sender, RoutedEventArgs e) + { + if (!(sender is MenuFlyoutItem item)) return; + + var selectedEditor = NotepadsCore.GetSelectedTextEditor(); + selectedEditor.IsReadOnly = item.Icon.Visibility == Visibility.Collapsed; + NotepadsCore.FocusOnTextEditor(selectedEditor); + } + private void FontZoomIndicatorFlyoutSelection_OnClick(object sender, RoutedEventArgs e) { if (!(sender is AppBarButton button)) return; @@ -360,12 +392,19 @@ private void FileModificationStateIndicatorClicked(ITextEditor selectedEditor) { if (selectedEditor.FileModificationState == FileModificationState.Modified) { + FileRemoveReadonlyFlagFlyoutItem.Visibility = Visibility.Collapsed; FileModificationStateIndicator.ContextFlyout.ShowAt(FileModificationStateIndicator); } else if (selectedEditor.FileModificationState == FileModificationState.RenamedMovedOrDeleted) { NotificationCenter.Instance.PostNotification(_resourceLoader.GetString("TextEditor_FileRenamedMovedOrDeletedIndicator_ToolTip"), 2500); } + else if (selectedEditor.IsReadOnly) + { + FileModifiedOutsideFlyoutReloadFileFromDiskFlyoutItem.Visibility = Visibility.Collapsed; + FileRemoveReadonlyFlagFlyoutItem.Text = _resourceLoader.GetString("Tab_ContextFlyout_ToggleReadOnlyOffButtonDisplayText"); + FileModificationStateIndicator.ContextFlyout.ShowAt(FileModificationStateIndicator); + } } private async void PathIndicatorClicked(ITextEditor selectedEditor) @@ -375,6 +414,7 @@ private async void PathIndicatorClicked(ITextEditor selectedEditor) PathIndicatorFlyoutCopyFullPathFlyoutItem.Text = _resourceLoader.GetString("Tab_ContextFlyout_CopyFullPathButtonDisplayText"); PathIndicatorFlyoutOpenContainingFolderFlyoutItem.Text = _resourceLoader.GetString("Tab_ContextFlyout_OpenContainingFolderButtonDisplayText"); PathIndicatorFlyoutFileRenameFlyoutItem.Text = _resourceLoader.GetString("Tab_ContextFlyout_RenameButtonDisplayText"); + PathIndicatorFlyoutToggleFileReadonlyFlyoutItem.Text = _resourceLoader.GetString("TextEditor_PathIndicator_ToggleMenuFlyoutItem_ToggleFileReadonly"); if (selectedEditor.FileModificationState == FileModificationState.RenamedMovedOrDeleted || (selectedEditor.EditingFile != null && FileSystemUtility.IsFileReadOnly(selectedEditor.EditingFile))) @@ -386,6 +426,17 @@ private async void PathIndicatorClicked(ITextEditor selectedEditor) PathIndicatorFlyoutFileRenameFlyoutItem.IsEnabled = true; } + if (selectedEditor.EditingFile == null) + { + PathIndicatorFlyoutToggleFileReadonlyFlyoutItem.Visibility = Visibility.Collapsed; + } + else + { + PathIndicatorFlyoutToggleFileReadonlyFlyoutItem.Visibility = Visibility.Visible; + PathIndicatorFlyoutToggleFileReadonlyFlyoutItem.IsEnabled = selectedEditor.FileModificationState == FileModificationState.Untouched; + PathIndicatorFlyoutToggleFileReadonlyFlyoutItem.Icon.Visibility = selectedEditor.IsReadOnly ? Visibility.Visible : Visibility.Collapsed; + } + if (App.IsGameBarWidget) { PathIndicatorFlyoutOpenContainingFolderFlyoutItem.Visibility = Visibility.Collapsed; diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.xaml b/src/Notepads/Views/MainPage/NotepadsMainPage.xaml index 4ea5550b2..90604afa2 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.xaml +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.xaml @@ -329,6 +329,13 @@ + + + + +