diff --git a/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs b/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs index 5898e605..ced1e191 100644 --- a/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs +++ b/src/OneWare.Core/ViewModels/DockViews/EditViewModel.cs @@ -20,8 +20,12 @@ namespace OneWare.Core.ViewModels.DockViews; -public class EditViewModel : ExtendedDocument, IEditor +public class EditViewModel : ExtendedDocument, IEditor, ISelfWatchingDocument { + private const string DetectExternalChangesKey = "Editor_DetectExternalChanges"; + private const string NotifyExternalChangesKey = "Editor_NotifyExternalChanges"; + private static readonly TimeSpan ExternalChangeDebounce = TimeSpan.FromMilliseconds(250); + private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.FromArgb(150, 175, 50, 50)); private static readonly IBrush WarningBrush = new SolidColorBrush(Color.FromArgb(150, 155, 155, 0)); private readonly BackupService _backupService; @@ -36,6 +40,16 @@ public class EditViewModel : ExtendedDocument, IEditor private IEnumerable? _diagnostics; + private bool _detectExternalChanges; + private bool _isClosed; + private FileSystemWatcher? _fileWatcher; + private string? _watchedPath; + private DispatcherTimer? _externalChangeTimer; + private bool _isApplyingExternalChange; + private bool _externalChangePending; + private DateTime? _declinedExternalWriteTime; + private FileConflictViewModel? _conflictView; + public EditViewModel(string fullPath, ILogger logger, IFileIconService fileIconService, ISettingsService settingsService, IMainDockService mainDockService, ILanguageManager languageManager, IWindowService windowService, IProjectExplorerService projectExplorerService, IErrorService errorService, @@ -114,6 +128,12 @@ public EditViewModel(string fullPath, ILogger logger, IFileIconService fileIconS { Editor.Options.IndentationSize = x; }); + + _settingsService.GetSettingObservable(DetectExternalChangesKey).Subscribe(x => + { + _detectExternalChanges = x; + UpdateFileWatcher(); + }); } public bool DisableEditViewEvents { get; private set; } @@ -149,6 +169,11 @@ protected override void UpdateCurrentFile(string? oldPath) Diagnostics = _errorService.GetErrorsForFile(FullPath); + _isClosed = false; + _declinedExternalWriteTime = null; + CloseConflictView(); + UpdateFileWatcher(); + async Task OnInitialized() { var result = await LoadAsync(); @@ -181,7 +206,11 @@ async Task OnInitialized() Observable.FromEventPattern( h => Editor.Document.TextChanged += h, h => Editor.Document.TextChanged -= h) - .Subscribe(x => { IsDirty = true; }).DisposeWith(_composite); + .Subscribe(x => + { + IsDirty = true; + _conflictView?.ScheduleRefresh(); + }).DisposeWith(_composite); if (result) InitTypeAssistance(); } @@ -306,6 +335,9 @@ public override bool OnClose() var r = base.OnClose(); if (!r) return false; _composite.Dispose(); + _isClosed = true; + StopFileWatcher(); + CloseConflictView(); return true; } @@ -333,10 +365,18 @@ protected override void Reset() _composite = new CompositeDisposable(); } - public override async Task SaveAsync() + public override Task SaveAsync() + { + return SaveAsync(false); + } + + /// Skips the check for changes made outside of OneWare Studio. + public async Task SaveAsync(bool overwriteExternalChanges) { if (IsReadOnly || string.IsNullOrWhiteSpace(FullPath)) return true; + if (!overwriteExternalChanges && !await ResolveSaveConflictAsync()) return false; + try { await PlatformHelper.WriteTextFileAsync(FullPath, CurrentDocument.Text); @@ -351,8 +391,10 @@ public override async Task SaveAsync() ?.Log($"Saved {Path.GetFileName(FullPath)}!"); IsDirty = false; - LastSaveTime = DateTime.Now; + LastSaveTime = GetLastWriteTimeOrNow(FullPath); + _declinedExternalWriteTime = null; LoadingFailed = false; + CloseConflictView(); FileSaved?.Invoke(this, EventArgs.Empty); @@ -368,12 +410,11 @@ private async Task LoadAsync() var success = true; try { - await using var stream = File.OpenRead(FullPath); - using var reader = new StreamReader(stream); + var (text, writeTime) = await ReadFileWithRetryAsync(FullPath); var doc = await Task.Run(() => { - var d = new TextDocument(reader.ReadToEnd()); + var d = new TextDocument(text); d.SetOwnerThread(null); return d; }); @@ -382,7 +423,7 @@ private async Task LoadAsync() Editor.Document = doc; - LastSaveTime = File.GetLastWriteTime(FullPath); + LastSaveTime = writeTime; CurrentDocument.UndoStack.ClearAll(); } @@ -403,6 +444,319 @@ private async Task LoadAsync() return success; } + internal static async Task<(string Text, DateTime WriteTime)> ReadFileWithRetryAsync(string path) + { + const int attempts = 5; + for (var attempt = 1;; attempt++) + try + { + // Take the timestamp before reading so a write that happens while reading is detected later + var writeTime = File.GetLastWriteTime(path); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, 4096, FileOptions.Asynchronous); + using var reader = new StreamReader(stream); + var text = await reader.ReadToEndAsync(); + return (text, writeTime); + } + catch (IOException) when (attempt < attempts && File.Exists(path)) + { + // Another program is still writing the file + await Task.Delay(50 * attempt); + } + catch (UnauthorizedAccessException) when (attempt < attempts) + { + await Task.Delay(50 * attempt); + } + } + + private static DateTime GetLastWriteTimeOrNow(string path) + { + try + { + return File.Exists(path) ? File.GetLastWriteTime(path) : DateTime.Now; + } + catch + { + return DateTime.Now; + } + } + + /// + /// Like VS Code: if the file on disk is newer and differs, ask to compare or overwrite before saving. + /// + /// True if saving may continue. + private async Task ResolveSaveConflictAsync() + { + try + { + if (!File.Exists(FullPath)) return true; + if (!LoadingFailed && File.GetLastWriteTime(FullPath) <= LastSaveTime) return true; + + var (diskText, _) = await ReadFileWithRetryAsync(FullPath); + if (diskText == CurrentDocument.Text) return true; + } + catch + { + return true; + } + + var compareButton = new MessageBoxButton + { + Text = "Compare", Role = MessageBoxButtonRole.Custom, Style = MessageBoxButtonStyle.Primary, + IsDefault = true + }; + var overwriteButton = new MessageBoxButton + { + Text = "Overwrite", Role = MessageBoxButtonRole.Yes, Style = MessageBoxButtonStyle.Danger + }; + var cancelButton = new MessageBoxButton { Text = "Cancel", Role = MessageBoxButtonRole.Cancel }; + + var result = await _windowService.ShowMessageBoxAsync(new MessageBoxRequest + { + Title = "File changed", + Message = + $"Failed to save '{Path.GetFileName(FullPath)}': The content of the file is newer.\n\n" + + "Compare your version with the file contents or overwrite the file with your changes.", + Icon = MessageBoxIcon.Warning, + Buttons = [compareButton, overwriteButton, cancelButton] + }, _mainDockService.GetWindowOwner(this)); + + if (result.Button == overwriteButton) return true; + if (result.Button == compareButton) ShowConflictView(); + return false; + } + + private void ShowConflictView() + { + if (_conflictView is not { IsClosed: false }) _conflictView = new FileConflictViewModel(this); + + _mainDockService.Show(_conflictView, DockShowLocation.Document); + _ = _conflictView.RefreshAsync(); + } + + private void CloseConflictView() + { + if (_conflictView is { IsClosed: false } view) _mainDockService.CloseDockable(view); + _conflictView = null; + } + + /// + /// Discards the unsaved changes and takes over the version on disk. Can be undone. + /// + public async Task RevertToDiskAsync() + { + if (string.IsNullOrWhiteSpace(FullPath) || !File.Exists(FullPath)) return; + + try + { + var (text, writeTime) = await ReadFileWithRetryAsync(FullPath); + + if (text != CurrentDocument.Text) ApplyExternalText(text); + + LastSaveTime = writeTime; + _declinedExternalWriteTime = null; + IsDirty = false; + LoadingFailed = false; + CloseConflictView(); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve()?.Error($"Could not revert {FullPath}", e); + } + } + + #endregion + + #region ExternalChanges + + private void UpdateFileWatcher() + { + var path = FullPath; + var shouldWatch = _detectExternalChanges && !_isClosed && !string.IsNullOrWhiteSpace(path) && + !OperatingSystem.IsBrowser(); + + if (shouldWatch && _fileWatcher != null && path.EqualPaths(_watchedPath)) return; + + StopFileWatcher(); + if (!shouldWatch) return; + + var directory = Path.GetDirectoryName(path); + if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) return; + + try + { + var watcher = new FileSystemWatcher(directory) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + IncludeSubdirectories = false, + Filter = Path.GetFileName(path) + }; + + watcher.Changed += OnWatchedFileChanged; + watcher.Created += OnWatchedFileChanged; + watcher.Renamed += OnWatchedFileChanged; + watcher.EnableRaisingEvents = true; + + _fileWatcher = watcher; + _watchedPath = path; + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + ?.Warning($"Could not watch {path} for external changes", e, false); + } + } + + private void StopFileWatcher() + { + _externalChangeTimer?.Stop(); + + if (_fileWatcher == null) return; + + _fileWatcher.EnableRaisingEvents = false; + _fileWatcher.Dispose(); + _fileWatcher = null; + _watchedPath = null; + } + + private void OnWatchedFileChanged(object sender, FileSystemEventArgs e) + { + Dispatcher.UIThread.Post(() => + { + if (sender != _fileWatcher || _watchedPath == null || !e.FullPath.EqualPaths(_watchedPath)) return; + ScheduleExternalReload(); + }); + } + + private void ScheduleExternalReload() + { + if (_isClosed) return; + + // Debounce, since editors often write a file in multiple steps (truncate, write, touch) + _externalChangeTimer ??= new DispatcherTimer(ExternalChangeDebounce, DispatcherPriority.Background, + (_, _) => + { + _externalChangeTimer?.Stop(); + _ = ReloadFromDiskAsync(); + }); + + _externalChangeTimer.Stop(); + _externalChangeTimer.Start(); + } + + private async Task ReloadFromDiskAsync() + { + if (_isClosed || string.IsNullOrWhiteSpace(FullPath)) return; + + if (IsLoading) + { + ScheduleExternalReload(); + return; + } + + if (_isApplyingExternalChange) + { + _externalChangePending = true; + return; + } + + _isApplyingExternalChange = true; + try + { + var path = FullPath; + if (!File.Exists(path)) return; + + (string Text, DateTime WriteTime) disk; + try + { + disk = await ReadFileWithRetryAsync(path); + } + catch (Exception e) + { + ContainerLocator.Container.Resolve() + ?.Warning($"Could not reload {path} after an external change", e, false); + return; + } + + if (_isClosed || IsLoading || !path.EqualPaths(FullPath)) return; + + if (disk.Text == CurrentDocument.Text) + { + LastSaveTime = disk.WriteTime; + IsDirty = false; + LoadingFailed = false; + CloseConflictView(); + return; + } + + // Like VS Code: keep unsaved changes, the conflict is resolved when saving + if (IsDirty) + { + _conflictView?.ScheduleRefresh(); + return; + } + + if (_declinedExternalWriteTime == disk.WriteTime) return; + + if (_settingsService.GetSettingValue(NotifyExternalChangesKey)) + { + var result = await _windowService.ShowYesNoAsync("File changed", + $"{Path.GetFileName(path)} has been changed outside of OneWare Studio.\n\nDo you want to reload it?", + MessageBoxIcon.Warning, _mainDockService.GetWindowOwner(this)); + + if (_isClosed || IsDirty || !path.EqualPaths(FullPath)) return; + + if (result != MessageBoxStatus.Yes) + { + // Keep the editor version and don't ask again for this disk version. + // Saving later still shows the conflict dialog. + _declinedExternalWriteTime = disk.WriteTime; + return; + } + } + + ApplyExternalText(disk.Text); + + LastSaveTime = disk.WriteTime; + _declinedExternalWriteTime = null; + IsDirty = false; + LoadingFailed = false; + } + finally + { + _isApplyingExternalChange = false; + + if (_externalChangePending) + { + _externalChangePending = false; + ScheduleExternalReload(); + } + } + } + + /// + /// Replaces only the changed range so caret, scroll position, folding and markers outside of it are kept. + /// + private void ApplyExternalText(string newText) + { + var document = CurrentDocument; + var oldText = document.Text; + + var maxPrefix = Math.Min(oldText.Length, newText.Length); + var prefix = 0; + while (prefix < maxPrefix && oldText[prefix] == newText[prefix]) prefix++; + if (prefix > 0 && oldText[prefix - 1] == '\r') prefix--; + + var maxSuffix = Math.Min(oldText.Length, newText.Length) - prefix; + var suffix = 0; + while (suffix < maxSuffix && + oldText[oldText.Length - 1 - suffix] == newText[newText.Length - 1 - suffix]) + suffix++; + if (suffix > 0 && oldText[oldText.Length - suffix] == '\n') suffix--; + + document.Replace(prefix, oldText.Length - prefix - suffix, + newText.Substring(prefix, newText.Length - prefix - suffix)); + } #endregion } diff --git a/src/OneWare.Core/ViewModels/DockViews/FileConflictViewModel.cs b/src/OneWare.Core/ViewModels/DockViews/FileConflictViewModel.cs new file mode 100644 index 00000000..4f21f2c7 --- /dev/null +++ b/src/OneWare.Core/ViewModels/DockViews/FileConflictViewModel.cs @@ -0,0 +1,104 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.Input; +using Dock.Model.Mvvm.Controls; +using Microsoft.Extensions.Logging; +using OneWare.Essentials.Controls; +using OneWare.Essentials.Helpers; +using OneWare.Essentials.Services; +using OneWare.Essentials.ViewModels; + +namespace OneWare.Core.ViewModels.DockViews; + +/// +/// Compares the file on disk (left) with the unsaved editor version (right) after a save conflict. +/// +public class FileConflictViewModel : Document, INoSerializeLayout +{ + private readonly EditViewModel _editor; + private readonly DispatcherTimer _refreshTimer; + private bool _isRefreshing; + private bool _refreshPending; + + public FileConflictViewModel(EditViewModel editor) + { + _editor = editor; + Id = $"FileConflict: {editor.FullPath}"; + Title = $"{Path.GetFileName(editor.FullPath)} (on disk) ↔ (yours)"; + LanguageExtension = Path.GetExtension(editor.FullPath); + + _refreshTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(300), DispatcherPriority.Background, (_, _) => + { + _refreshTimer?.Stop(); + _ = RefreshAsync(); + }); + _refreshTimer.Stop(); + + OverwriteCommand = new AsyncRelayCommand(() => _editor.SaveAsync(true)); + UseFileVersionCommand = new AsyncRelayCommand(_editor.RevertToDiskAsync); + } + + public string FileName => Path.GetFileName(_editor.FullPath); + + public string LanguageExtension { get; } + + public ICollection? Chunks + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsClosed { get; private set; } + + public IAsyncRelayCommand OverwriteCommand { get; } + + public IAsyncRelayCommand UseFileVersionCommand { get; } + + public void ScheduleRefresh() + { + if (IsClosed) return; + _refreshTimer.Stop(); + _refreshTimer.Start(); + } + + public async Task RefreshAsync() + { + if (IsClosed) return; + + if (_isRefreshing) + { + _refreshPending = true; + return; + } + + _isRefreshing = true; + try + { + var path = _editor.FullPath; + var mine = _editor.CurrentDocument.Text; + var disk = File.Exists(path) ? (await EditViewModel.ReadFileWithRetryAsync(path)).Text : string.Empty; + + var chunks = await Task.Run(() => DiffHelper.BuildDiff(disk, mine)); + if (!IsClosed) Chunks = chunks; + } + catch (Exception e) + { + ContainerLocator.Container.Resolve()?.Warning($"Could not compare {FileName}", e, false); + } + finally + { + _isRefreshing = false; + if (_refreshPending) + { + _refreshPending = false; + ScheduleRefresh(); + } + } + } + + public override bool OnClose() + { + IsClosed = true; + _refreshTimer.Stop(); + return base.OnClose(); + } +} diff --git a/src/OneWare.Core/Views/DockViews/FileConflictView.axaml b/src/OneWare.Core/Views/DockViews/FileConflictView.axaml new file mode 100644 index 00000000..7fdfddcb --- /dev/null +++ b/src/OneWare.Core/Views/DockViews/FileConflictView.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/src/OneWare.Core/Views/DockViews/FileConflictView.axaml.cs b/src/OneWare.Core/Views/DockViews/FileConflictView.axaml.cs new file mode 100644 index 00000000..8fa5fcf7 --- /dev/null +++ b/src/OneWare.Core/Views/DockViews/FileConflictView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace OneWare.Core.Views.DockViews; + +public partial class FileConflictView : UserControl +{ + public FileConflictView() + { + InitializeComponent(); + } +} diff --git a/src/OneWare.Essentials/ViewModels/ISelfWatchingDocument.cs b/src/OneWare.Essentials/ViewModels/ISelfWatchingDocument.cs new file mode 100644 index 00000000..907e4b5b --- /dev/null +++ b/src/OneWare.Essentials/ViewModels/ISelfWatchingDocument.cs @@ -0,0 +1,9 @@ +namespace OneWare.Essentials.ViewModels; + +/// +/// Marks a document that detects and applies external changes of its file itself. +/// Project watchers will not reload such documents when the file changes on disk. +/// +public interface ISelfWatchingDocument +{ +} diff --git a/src/OneWare.ProjectExplorer/Services/ProjectWatchInstance.cs b/src/OneWare.ProjectExplorer/Services/ProjectWatchInstance.cs index ef1d0807..32fc4df2 100644 --- a/src/OneWare.ProjectExplorer/Services/ProjectWatchInstance.cs +++ b/src/OneWare.ProjectExplorer/Services/ProjectWatchInstance.cs @@ -6,6 +6,7 @@ using OneWare.Essentials.Extensions; using OneWare.Essentials.Models; using OneWare.Essentials.Services; +using OneWare.Essentials.ViewModels; namespace OneWare.ProjectExplorer.Services; @@ -230,7 +231,7 @@ private async Task HandleFileTouchAsync(string path) var openTab = _mainDockService.OpenFiles .FirstOrDefault(x => x.Key.EqualPaths(path)); - if (openTab.Value != null && File.Exists(path)) + if (openTab.Value is not null and not ISelfWatchingDocument && File.Exists(path)) { var lastWriteTime = File.GetLastWriteTime(path); if (lastWriteTime > openTab.Value.LastSaveTime)