diff --git a/src/Notepads/Core/INotepadsCore.cs b/src/Notepads/Core/INotepadsCore.cs index c6f953414..3bde4269f 100644 --- a/src/Notepads/Core/INotepadsCore.cs +++ b/src/Notepads/Core/INotepadsCore.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -33,6 +33,7 @@ public interface INotepadsCore event EventHandler TextEditorLineEndingChanged; event EventHandler TextEditorModeChanged; event EventHandler TextEditorMovedToAnotherAppInstance; + event EventHandler TextEditorTextChanging; event EventHandler> StorageItemsDropped; event KeyEventHandler TextEditorKeyDown; diff --git a/src/Notepads/Core/NotepadsCore.cs b/src/Notepads/Core/NotepadsCore.cs index 8f1a7b2f7..edf3623b3 100644 --- a/src/Notepads/Core/NotepadsCore.cs +++ b/src/Notepads/Core/NotepadsCore.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -44,6 +44,7 @@ public class NotepadsCore : INotepadsCore public event EventHandler TextEditorLineEndingChanged; public event EventHandler TextEditorModeChanged; public event EventHandler TextEditorMovedToAnotherAppInstance; + public event EventHandler TextEditorTextChanging; public event EventHandler> StorageItemsDropped; public event KeyEventHandler TextEditorKeyDown; @@ -207,6 +208,7 @@ public ITextEditor CreateTextEditor( textEditor.LineEndingChanged += TextEditor_OnLineEndingChanged; textEditor.EncodingChanged += TextEditor_OnEncodingChanged; textEditor.FileRenamed += TextEditor_OnFileRenamed; + textEditor.TextChanging += TextEditor_OnTextChanging; return textEditor; } @@ -245,6 +247,7 @@ public void DeleteTextEditor(ITextEditor textEditor) textEditor.LineEndingChanged -= TextEditor_OnLineEndingChanged; textEditor.EncodingChanged -= TextEditor_OnEncodingChanged; textEditor.FileRenamed -= TextEditor_OnFileRenamed; + textEditor.TextChanging -= TextEditor_OnTextChanging; textEditor.Dispose(); } @@ -574,6 +577,12 @@ private void TextEditor_OnFileRenamed(object sender, EventArgs e) TextEditorRenamed?.Invoke(this, textEditor); } + private void TextEditor_OnTextChanging(object sender, EventArgs e) + { + if (!(sender is ITextEditor textEditor)) return; + TextEditorTextChanging?.Invoke(this, textEditor); + } + #region DragAndDrop private async void Sets_DragOver(object sender, DragEventArgs args) diff --git a/src/Notepads/Notepads.csproj b/src/Notepads/Notepads.csproj index 2bc8adb78..6738a0beb 100644 --- a/src/Notepads/Notepads.csproj +++ b/src/Notepads/Notepads.csproj @@ -1,4 +1,4 @@ - + @@ -238,7 +238,9 @@ + + diff --git a/src/Notepads/Services/AppSettingsService.cs b/src/Notepads/Services/AppSettingsService.cs index 66c62f5ec..5cf52338c 100644 --- a/src/Notepads/Services/AppSettingsService.cs +++ b/src/Notepads/Services/AppSettingsService.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -27,6 +27,7 @@ public static class AppSettingsService public static event EventHandler OnStatusBarVisibilityChanged; public static event EventHandler OnSessionBackupAndRestoreOptionChanged; public static event EventHandler OnHighlightMisspelledWordsChanged; + public static event EventHandler OnAutosaveOptionChanged; private static string _editorFontFamily; @@ -239,6 +240,19 @@ public static bool IsSessionSnapshotEnabled } } + private static bool _isAutosaveEnabled; + + public static bool IsAutosaveEnabled + { + get => _isAutosaveEnabled; + set + { + _isAutosaveEnabled = value; + OnAutosaveOptionChanged?.Invoke(null, value); + ApplicationSettingsStore.Write(SettingsKey.EditorEnableAutosaveBool, value); + } + } + private static bool _isHighlightMisspelledWordsEnabled; public static bool IsHighlightMisspelledWordsEnabled @@ -327,6 +341,8 @@ public static void Initialize() InitializeSessionSnapshotSettings(); + InitializeAutosaveSettings(); + InitializeAppOpeningPreferencesSettings(); InitializeAppClosingPreferencesSettings(); @@ -368,6 +384,18 @@ private static void InitializeSessionSnapshotSettings() } } + private static void InitializeAutosaveSettings() + { + if (ApplicationSettingsStore.Read(SettingsKey.EditorEnableAutosaveBool) is bool enableAutosave) + { + _isAutosaveEnabled = enableAutosave; + } + else + { + _isAutosaveEnabled = false; + } + } + private static void InitializeLineEndingSettings() { if (ApplicationSettingsStore.Read(SettingsKey.EditorDefaultLineEndingStr) is string lineEndingStr && diff --git a/src/Notepads/Services/EnhancedAutosaveService.cs b/src/Notepads/Services/EnhancedAutosaveService.cs new file mode 100644 index 000000000..92337d6b1 --- /dev/null +++ b/src/Notepads/Services/EnhancedAutosaveService.cs @@ -0,0 +1,139 @@ +// --------------------------------------------------------------------------------------------- +// Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. +// See LICENSE file in the project root for license information. +// --------------------------------------------------------------------------------------------- + +namespace Notepads.Services +{ + using System; + using System.Collections.Concurrent; + using System.Diagnostics; + using System.IO; + using System.Text.Json; + using System.Threading.Tasks; + using Windows.Storage; + + public class AutosaveSettings + { + public bool AutosaveEnabled { get; set; } = false; + public bool ShowSavedNotification { get; set; } = true; + } + + public static class EnhancedAutosaveService + { + public static bool FeatureFlag_EnhancedAutosave = true; + + private static string SettingsFilePath => Path.Combine(ApplicationData.Current.LocalFolder.Path, "settings", "autosave.json"); + + private static AutosaveSettings _settings = new AutosaveSettings(); + + public static bool IsAutosaveEnabled + { + get => _settings.AutosaveEnabled; + set + { + if (_settings.AutosaveEnabled != value) + { + _settings.AutosaveEnabled = value; + SaveSettings(); + AutosaveStateChanged?.Invoke(null, value); + } + } + } + + public static bool ShowSavedNotification + { + get => _settings.ShowSavedNotification; + set + { + if (_settings.ShowSavedNotification != value) + { + _settings.ShowSavedNotification = value; + SaveSettings(); + NotificationSettingChanged?.Invoke(null, value); + } + } + } + + public static event EventHandler AutosaveStateChanged; + public static event EventHandler NotificationSettingChanged; + public static event EventHandler LastSaveTimeChanged; + + public static DateTime LastSaveTime { get; private set; } + + private static BlockingCollection> _saveQueue; + + public static void Initialize() + { + if (!FeatureFlag_EnhancedAutosave) return; + LoadSettings(); + _saveQueue = new BlockingCollection>(); + StartAsyncQueue(); + } + + private static void LoadSettings() + { + try + { + if (File.Exists(SettingsFilePath)) + { + string json = File.ReadAllText(SettingsFilePath); + _settings = JsonSerializer.Deserialize(json) ?? new AutosaveSettings(); + } + } + catch (Exception ex) + { + LoggingService.LogError($"[{nameof(EnhancedAutosaveService)}] LoadSettings error: {ex.Message}"); + } + } + + private static void SaveSettings() + { + try + { + string dir = Path.GetDirectoryName(SettingsFilePath); + if (!Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + string json = JsonSerializer.Serialize(_settings); + File.WriteAllText(SettingsFilePath, json); + } + catch (Exception ex) + { + LoggingService.LogError($"[{nameof(EnhancedAutosaveService)}] SaveSettings error: {ex.Message}"); + } + } + + private static void StartAsyncQueue() + { + Task.Run(async () => + { + foreach (var saveAction in _saveQueue.GetConsumingEnumerable()) + { + try + { + var sw = Stopwatch.StartNew(); + await saveAction(); + sw.Stop(); + LastSaveTime = DateTime.Now; + LastSaveTimeChanged?.Invoke(null, LastSaveTime); + LoggingService.LogInfo($"[{nameof(EnhancedAutosaveService)}] Save completed in {sw.ElapsedMilliseconds}ms. IO count: 1"); + } + catch (Exception ex) + { + LoggingService.LogError($"[{nameof(EnhancedAutosaveService)}] AsyncQueue error: {ex.Message}"); + } + } + }); + } + + public static void QueueSave(Func saveAction) + { + if (FeatureFlag_EnhancedAutosave && IsAutosaveEnabled && _saveQueue != null) + { + _saveQueue.Add(saveAction); + } + } + } +} diff --git a/src/Notepads/Settings/SettingsKey.cs b/src/Notepads/Settings/SettingsKey.cs index 68fab41f6..9064ebfed 100644 --- a/src/Notepads/Settings/SettingsKey.cs +++ b/src/Notepads/Settings/SettingsKey.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -41,5 +41,6 @@ internal static class SettingsKey internal static string EditorHighlightMisspelledWordsBool = "EditorHighlightMisspelledWordsBool"; internal static string EditorDefaultDisplayLineNumbersBool = "EditorDefaultDisplayLineNumbersBool"; internal static string EditorEnableSmartCopyBool = "EditorEnableSmartCopyBool"; + internal static string EditorEnableAutosaveBool = "EditorEnableAutosaveBool"; } } \ No newline at end of file diff --git a/src/Notepads/Tests/EnhancedAutosaveTests.cs b/src/Notepads/Tests/EnhancedAutosaveTests.cs new file mode 100644 index 000000000..4f70e921f --- /dev/null +++ b/src/Notepads/Tests/EnhancedAutosaveTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; +using Windows.Storage; +using Notepads.Services; +using Notepads.Views.MainPage; +using Windows.UI.Core; + +namespace Notepads.Tests +{ + public static class EnhancedAutosaveTests + { + public static async Task RunTestsAsync(NotepadsMainPage mainPage) + { + LoggingService.LogInfo("Starting EnhancedAutosaveTests..."); + + // Ensure EnhancedAutosave is enabled + EnhancedAutosaveService.FeatureFlag_EnhancedAutosave = true; + EnhancedAutosaveService.IsAutosaveEnabled = true; + EnhancedAutosaveService.ShowSavedNotification = true; + + // 1. Changing a file triggers an immediate save event. + // Create a temp file and load it + StorageFile tempFile = await ApplicationData.Current.LocalFolder.CreateFileAsync("test_autosave.txt", CreationCollisionOption.ReplaceExisting); + await mainPage.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, async () => + { + await mainPage.OpenFileAsync(tempFile); + + var editor = mainPage.GetTextEditor(); + + // Simulate typing + editor.TypeText("Automated Test Content"); + + LoggingService.LogInfo("Test 1: Content changed, waiting for autosave..."); + }); + + // Wait for 300ms debounce + queue processing + await Task.Delay(1000); + + // Check if file was saved + string savedContent = await FileIO.ReadTextAsync(tempFile); + if (savedContent == "Automated Test Content") + { + LoggingService.LogInfo("Test 1 Passed: File was successfully autosaved."); + } + else + { + LoggingService.LogError("Test 1 Failed: File was not autosaved."); + } + + // 2. Status-bar icon updates color and label + // (Requires UI inspection, but we can verify the service state) + LoggingService.LogInfo($"Test 2: LastSaveTime is {EnhancedAutosaveService.LastSaveTime}"); + + // 3. Clicking the icon flips the persisted setting + EnhancedAutosaveService.IsAutosaveEnabled = false; + if (!EnhancedAutosaveService.IsAutosaveEnabled) + { + LoggingService.LogInfo("Test 3 Passed: Autosave disabled successfully."); + } + + // 4. Notification toggle disabled when autosave is off + // Checked in UI bindings + + // 5. No save popup appears when toggle is off, but file is still written + EnhancedAutosaveService.IsAutosaveEnabled = true; + EnhancedAutosaveService.ShowSavedNotification = false; + + await mainPage.Dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => + { + var editor = mainPage.GetTextEditor(); + editor.TypeText("Silent Save Content"); + }); + + await Task.Delay(1000); + + savedContent = await FileIO.ReadTextAsync(tempFile); + if (savedContent == "Silent Save Content") + { + LoggingService.LogInfo("Test 5 Passed: File was silently autosaved."); + } + else + { + LoggingService.LogError("Test 5 Failed: Silent autosave failed."); + } + + // Cleanup + await tempFile.DeleteAsync(); + LoggingService.LogInfo("EnhancedAutosaveTests completed."); + } + } +} diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs b/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs index 67393ec2b..4da6f3bcf 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.IO.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -156,7 +156,9 @@ private async Task SaveInternalAsync(ITextEditor textEditor, StorageFile file, b } } - private async Task SaveAsync(ITextEditor textEditor, bool saveAs, bool ignoreUnmodifiedDocument = false, bool rebuildOpenRecentItems = true) + private bool _isAutosaving; + + private async Task SaveAsync(ITextEditor textEditor, bool saveAs, bool ignoreUnmodifiedDocument = false, bool rebuildOpenRecentItems = true, bool isAutosave = false) { if (textEditor == null) return false; @@ -171,6 +173,7 @@ private async Task SaveAsync(ITextEditor textEditor, bool saveAs, bool ign { if (textEditor.EditingFile == null || saveAs) { + if (isAutosave) return false; // Do not prompt Save As during autosave file = await OpenFileUsingFileSavePickerAsync(textEditor); if (file == null) return false; // User cancelled } @@ -182,6 +185,7 @@ private async Task SaveAsync(ITextEditor textEditor, bool saveAs, bool ign bool promptSaveAs = false; try { + if (isAutosave) _isAutosaving = true; await SaveInternalAsync(textEditor, file, rebuildOpenRecentItems); } catch (UnauthorizedAccessException) // Happens when the file we are saving is read-only @@ -192,13 +196,24 @@ private async Task SaveAsync(ITextEditor textEditor, bool saveAs, bool ign { promptSaveAs = true; } + finally + { + if (isAutosave) _isAutosaving = false; + } if (promptSaveAs) { + if (isAutosave) return false; file = await OpenFileUsingFileSavePickerAsync(textEditor); if (file == null) return false; // User cancelled - await SaveInternalAsync(textEditor, file, rebuildOpenRecentItems); + try + { + await SaveInternalAsync(textEditor, file, rebuildOpenRecentItems); + } + finally + { + } return true; } @@ -206,6 +221,11 @@ private async Task SaveAsync(ITextEditor textEditor, bool saveAs, bool ign } catch (Exception ex) { + if (isAutosave) + { + NotificationCenter.Instance.PostNotification($"Autosave failed: {ex.Message}", 3500); + return false; + } var fileSaveErrorDialog = new FileSaveErrorDialog((file == null) ? string.Empty : file.Path, ex.Message); await DialogManager.OpenDialogAsync(fileSaveErrorDialog, awaitPreviousDialog: false); if (!fileSaveErrorDialog.IsAborted) diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs b/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs index b5001164d..5d738d4cf 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.StatusBar.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -44,6 +44,39 @@ private void SetupStatusBar(ITextEditor textEditor) UpdateLineEndingIndicator(textEditor.GetLineEnding()); UpdateEncodingIndicator(textEditor.GetEncoding()); UpdateShadowWindowIndicator(); + UpdateAutosaveIndicatorVisibility(); + } + + private void UpdateAutosaveIndicatorVisibility() + { + if (StatusBar == null) return; + if (EnhancedAutosaveService.FeatureFlag_EnhancedAutosave) + { + if (AutosaveIndicatorPanel != null) + { + AutosaveIndicatorPanel.Visibility = Visibility.Visible; + if (EnhancedAutosaveService.IsAutosaveEnabled) + { + AutosaveIcon.Foreground = new Windows.UI.Xaml.Media.SolidColorBrush(Windows.UI.Colors.LimeGreen); + AutosaveIndicatorText.Text = "Autosave: ON"; + } + else + { + AutosaveIcon.Foreground = new Windows.UI.Xaml.Media.SolidColorBrush(Windows.UI.Colors.Gray); + AutosaveIndicatorText.Text = "Autosave: OFF"; + } + + string lastSave = EnhancedAutosaveService.LastSaveTime == DateTime.MinValue ? "Never" : EnhancedAutosaveService.LastSaveTime.ToString("HH:mm:ss.fff"); + ToolTipService.SetToolTip(AutosaveIndicatorPanel, $"State: {(EnhancedAutosaveService.IsAutosaveEnabled ? "ON" : "OFF")}\nLast save: {lastSave}"); + } + } + else + { + if (AutosaveIndicatorPanel != null) + { + AutosaveIndicatorPanel.Visibility = Visibility.Collapsed; + } + } } public void ShowHideStatusBar(bool showStatusBar) @@ -354,6 +387,11 @@ private void StatusBarComponent_OnTapped(object sender, TappedRoutedEventArgs e) { EncodingIndicatorClicked(selectedEditor); } + else if (sender == AutosaveIndicatorPanel) + { + EnhancedAutosaveService.IsAutosaveEnabled = !EnhancedAutosaveService.IsAutosaveEnabled; + UpdateAutosaveIndicatorVisibility(); + } else if (sender == ShadowWindowIndicator) { ShadowWindowIndicatorClicked(); diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.xaml b/src/Notepads/Views/MainPage/NotepadsMainPage.xaml index e9bc197b7..f082df2a1 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.xaml +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notepads/Views/MainPage/NotepadsMainPage.xaml.cs b/src/Notepads/Views/MainPage/NotepadsMainPage.xaml.cs index 423b32613..d893164e0 100644 --- a/src/Notepads/Views/MainPage/NotepadsMainPage.xaml.cs +++ b/src/Notepads/Views/MainPage/NotepadsMainPage.xaml.cs @@ -1,4 +1,4 @@ -// --------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------- // Copyright (c) 2019-2024, Jiaqi (0x7c13) Liu. All rights reserved. // See LICENSE file in the project root for license information. // --------------------------------------------------------------------------------------------- @@ -83,6 +83,13 @@ private INotepadsCore NotepadsCore private readonly string _defaultNewFileName; + public ITextEditor GetTextEditor() + { + return NotepadsCore.GetSelectedTextEditor(); + } + + private DispatcherTimer _autosaveTimer; + public NotepadsMainPage() { InitializeComponent(); @@ -95,6 +102,7 @@ public NotepadsMainPage() InitializeNotificationCenter(); InitializeThemeSettings(); InitializeStatusBar(); + InitializeAutosave(); InitializeControls(); InitializeMainMenu(); InitializeKeyboardShortcuts(); @@ -173,6 +181,63 @@ private static async Task OpenNewAppInstanceAsync() } } + private void InitializeAutosave() + { + if (!EnhancedAutosaveService.FeatureFlag_EnhancedAutosave) return; + EnhancedAutosaveService.Initialize(); + + _autosaveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; + _autosaveTimer.Tick += AutosaveTimer_Tick; + + NotepadsCore.TextEditorTextChanging += NotepadsCore_TextEditorTextChanging; + EnhancedAutosaveService.AutosaveStateChanged += EnhancedAutosaveService_AutosaveStateChanged; + EnhancedAutosaveService.LastSaveTimeChanged += EnhancedAutosaveService_LastSaveTimeChanged; + UpdateAutosaveIndicatorVisibility(); + } + + private void EnhancedAutosaveService_AutosaveStateChanged(object sender, bool isAutosaveEnabled) + { + UpdateAutosaveIndicatorVisibility(); + } + + private async void EnhancedAutosaveService_LastSaveTimeChanged(object sender, DateTime time) + { + await Dispatcher.CallOnUIThreadAsync(() => + { + UpdateAutosaveIndicatorVisibility(); + }); + } + + private void NotepadsCore_TextEditorTextChanging(object sender, ITextEditor textEditor) + { + if (EnhancedAutosaveService.IsAutosaveEnabled) + { + _autosaveTimer.Stop(); + _autosaveTimer.Start(); + } + } + + private void AutosaveTimer_Tick(object sender, object e) + { + _autosaveTimer.Stop(); + if (!EnhancedAutosaveService.IsAutosaveEnabled) return; + + var editors = NotepadsCore.GetAllTextEditors(); + foreach (var editor in editors) + { + if (editor.IsModified && editor.EditingFile != null && editor.FileModificationState == FileModificationState.Untouched) + { + EnhancedAutosaveService.QueueSave(async () => + { + await Dispatcher.CallOnUIThreadAsync(async () => + { + await SaveAsync(editor, saveAs: false, ignoreUnmodifiedDocument: true, rebuildOpenRecentItems: false, isAutosave: true); + }); + }); + } + } + } + #region Application Life Cycle & Window management // Handles external links or cmd args activation before Sets loaded @@ -538,6 +603,12 @@ private void OnTextEditorSaved(object sender, ITextEditor textEditor) { SetupStatusBar(textEditor); } + + if (_isAutosaving && !EnhancedAutosaveService.ShowSavedNotification) + { + return; + } + NotificationCenter.Instance.PostNotification(_resourceLoader.GetString("TextEditor_NotificationMsg_FileSaved"), 1500); } diff --git a/src/Notepads/Views/Settings/AdvancedSettingsPage.xaml b/src/Notepads/Views/Settings/AdvancedSettingsPage.xaml index 12df8faa2..75b80ff39 100644 --- a/src/Notepads/Views/Settings/AdvancedSettingsPage.xaml +++ b/src/Notepads/Views/Settings/AdvancedSettingsPage.xaml @@ -1,4 +1,4 @@ - + + + + + +