diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index 66e246bc0..25dd15606 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -18,7 +19,9 @@ public LockContext(Watcher target) public void Dispose() { - Interlocked.Decrement(ref _target._lockCount); + var target = Interlocked.Exchange(ref _target, null); + if (target != null && Interlocked.Decrement(ref target._lockCount) == 0) + target.RefreshAfterOperation(); } private Watcher _target; @@ -79,20 +82,39 @@ public Watcher(IRepository repo, string fullpath, string gitDir) } _timer = new Timer(Tick, null, 100, 100); + ViewModels.Preferences.Instance.PropertyChanged += OnPreferencesChanged; // Starts filesystem watchers in another thread to avoid UI blocking - Task.Run(() => + Task.Run(UpdateWatching); + } + + private void UpdateWatching() + { + lock (_syncRoot) { + if (_disposed) + return; + try { foreach (var watcher in _watchers) - watcher.EnableRaisingEvents = true; + watcher.EnableRaisingEvents = !ViewModels.Preferences.Instance.ManualRefresh; } catch { - // Ignore exceptions. This may occur while `Dispose` is called. + // Ignore failures while starting filesystem watchers. } - }); + } + } + + private void OnPreferencesChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(ViewModels.Preferences.ManualRefresh)) + return; + + UpdateWatching(); + if (!ViewModels.Preferences.Instance.ManualRefresh) + OnWatcherError(null, null); // Reload changes missed while filesystem watchers were disabled. } public IDisposable Lock() @@ -100,6 +122,23 @@ public IDisposable Lock() return new LockContext(this); } + private void RefreshAfterOperation() + { + // Application operations use Lock() while changing the repository. In manual mode + // there are no filesystem events to trigger their final refresh, so do it explicitly + // when the outermost operation completes, including failed or cancelled operations. + if (_disposed || !ViewModels.Preferences.Instance.ManualRefresh) + return; + + _repo.RefreshBranches(); + _repo.RefreshWorktrees(); + _repo.RefreshWorkingCopyChanges(); + _repo.RefreshSubmodules(); + _repo.RefreshStashes(); + _repo.RefreshTags(); + _repo.RefreshCommits(); + } + public void MarkBranchUpdated() { Interlocked.Exchange(ref _updateBranch, 0); @@ -128,23 +167,36 @@ public void MarkSubmodulesUpdated() public void Dispose() { - foreach (var watcher in _watchers) + ViewModels.Preferences.Instance.PropertyChanged -= OnPreferencesChanged; + lock (_syncRoot) { - watcher.EnableRaisingEvents = false; - watcher.Dispose(); - } + if (_disposed) + return; - _watchers.Clear(); - _timer.Dispose(); - _timer = null; + _disposed = true; + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + + _watchers.Clear(); + _timer.Dispose(); + _timer = null; + } } private void Tick(object sender) { - if (Interlocked.Read(ref _lockCount) > 0) + if (ViewModels.Preferences.Instance.ManualRefresh || Interlocked.Read(ref _lockCount) > 0) return; var now = DateTime.Now.ToFileTime(); + // Wait for a quiet period across all event types before refreshing anything. + var quietPeriod = (long)ViewModels.Preferences.Instance.RefreshThrottling * TimeSpan.TicksPerMillisecond; + if (now - Interlocked.Read(ref _lastChange) <= quietPeriod) + return; + var refreshCommits = false; var refreshSubmodules = false; var refreshWC = false; @@ -249,7 +301,10 @@ private void OnRepositoryChanged(object o, FileSystemEventArgs e) /// private void OnWatcherError(object o, ErrorEventArgs e) { - var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + if (ViewModels.Preferences.Instance.ManualRefresh) + return; + + var desired = RecordChange(); Interlocked.Exchange(ref _updateBranch, desired); Interlocked.Exchange(ref _updateTags, desired); Interlocked.Exchange(ref _updateStashes, desired); @@ -283,6 +338,9 @@ private void OnWorkingCopyChanged(object o, FileSystemEventArgs e) private void HandleGitDirFileChanged(string name) { + if (ViewModels.Preferences.Instance.ManualRefresh) + return; + if (name.Contains("fsmonitor--daemon/", StringComparison.Ordinal) || name.EndsWith(".lock", StringComparison.Ordinal) || name.StartsWith("lfs/", StringComparison.Ordinal)) @@ -293,7 +351,7 @@ private void HandleGitDirFileChanged(string name) if (name.EndsWith("/HEAD", StringComparison.Ordinal) || name.EndsWith("/ORIG_HEAD", StringComparison.Ordinal)) { - var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + var desired = RecordChange(); Interlocked.Exchange(ref _updateSubmodules, desired); Interlocked.Exchange(ref _updateWC, desired); } @@ -302,15 +360,15 @@ private void HandleGitDirFileChanged(string name) name.Equals("AUTO_MERGE", StringComparison.Ordinal)) { if (_repo.MayHaveSubmodules()) - Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime()); + Interlocked.Exchange(ref _updateSubmodules, RecordChange()); } else if (name.StartsWith("refs/tags", StringComparison.Ordinal)) { - Interlocked.Exchange(ref _updateTags, DateTime.Now.AddSeconds(.5).ToFileTime()); + Interlocked.Exchange(ref _updateTags, RecordChange()); } else if (name.StartsWith("refs/stash", StringComparison.Ordinal) || name.Equals("logs/refs/stash", StringComparison.Ordinal)) { - Interlocked.Exchange(ref _updateStashes, DateTime.Now.AddSeconds(.5).ToFileTime()); + Interlocked.Exchange(ref _updateStashes, RecordChange()); } else if (name.Equals("HEAD", StringComparison.Ordinal) || name.Equals("BISECT_START", StringComparison.Ordinal) || @@ -318,35 +376,38 @@ private void HandleGitDirFileChanged(string name) name.StartsWith("refs/remotes/", StringComparison.Ordinal) || (name.StartsWith("worktrees/", StringComparison.Ordinal) && name.EndsWith("/HEAD", StringComparison.Ordinal))) { - Interlocked.Exchange(ref _updateBranch, DateTime.Now.AddSeconds(.5).ToFileTime()); + Interlocked.Exchange(ref _updateBranch, RecordChange()); } else if (name.Equals("packed-refs", StringComparison.Ordinal)) { - var desired = DateTime.Now.AddSeconds(.5).ToFileTime(); + var desired = RecordChange(); Interlocked.Exchange(ref _updateBranch, desired); Interlocked.Exchange(ref _updateTags, desired); } else if (name.StartsWith("reftable/", StringComparison.Ordinal)) { - var desired = DateTime.Now.AddSeconds(.5).ToFileTime(); + var desired = RecordChange(); Interlocked.Exchange(ref _updateBranch, desired); Interlocked.Exchange(ref _updateTags, desired); Interlocked.Exchange(ref _updateStashes, desired); } else if (name.StartsWith("objects/", StringComparison.Ordinal) || name.Equals("index", StringComparison.Ordinal)) { - Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime()); + Interlocked.Exchange(ref _updateWC, RecordChange()); } } private void HandleWorkingCopyFileChanged(string name, string fullpath) { + if (ViewModels.Preferences.Instance.ManualRefresh) + return; + if (name.StartsWith(".vs/", StringComparison.Ordinal)) return; if (name.Equals(".gitmodules", StringComparison.Ordinal)) { - var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + var desired = RecordChange(); Interlocked.Exchange(ref _updateSubmodules, desired); Interlocked.Exchange(ref _updateWC, desired); return; @@ -355,11 +416,11 @@ private void HandleWorkingCopyFileChanged(string name, string fullpath) var dir = Directory.Exists(fullpath) ? fullpath : Path.GetDirectoryName(fullpath); if (IsInSubmodule(dir)) { - Interlocked.Exchange(ref _updateSubmodules, DateTime.Now.AddSeconds(1).ToFileTime()); + Interlocked.Exchange(ref _updateSubmodules, RecordChange()); return; } - Interlocked.Exchange(ref _updateWC, DateTime.Now.AddSeconds(1).ToFileTime()); + Interlocked.Exchange(ref _updateWC, RecordChange()); } private bool IsInSubmodule(string folder) @@ -373,12 +434,22 @@ private bool IsInSubmodule(string folder) return IsInSubmodule(Path.GetDirectoryName(folder)); } + private long RecordChange() + { + var now = DateTime.Now.ToFileTime(); + Interlocked.Exchange(ref _lastChange, now); + return now; + } + private readonly IRepository _repo; + private readonly object _syncRoot = new object(); + private bool _disposed; private readonly string _root; private List _watchers; private Timer _timer; private long _lockCount; + private long _lastChange; private long _updateWC; private long _updateBranch; private long _updateSubmodules; diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index 62ac98e48..e8999bff2 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -707,7 +707,9 @@ Date Format Enable compact folders in changes tree Language + Manual refresh History Commits + Refresh throttling Show `LOCAL CHANGES` page by default Show `CHANGES` tab in commit detail by default Show relative time in commit graph diff --git a/src/ViewModels/AssumeUnchangedManager.cs b/src/ViewModels/AssumeUnchangedManager.cs index 8cd4a1663..e0c76eb77 100644 --- a/src/ViewModels/AssumeUnchangedManager.cs +++ b/src/ViewModels/AssumeUnchangedManager.cs @@ -26,6 +26,7 @@ public async Task RemoveAsync(string file) { if (!string.IsNullOrEmpty(file)) { + using var lockWatcher = _repo.LockWatcher(); var log = _repo.CreateLog("Remove Assume Unchanged File"); await new Commands.AssumeUnchanged(_repo.FullPath, file, false) diff --git a/src/ViewModels/Preferences.cs b/src/ViewModels/Preferences.cs index 9d9fb3f9f..f0def6297 100644 --- a/src/ViewModels/Preferences.cs +++ b/src/ViewModels/Preferences.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -135,6 +135,18 @@ public int MaxHistoryCommits set => SetProperty(ref _maxHistoryCommits, value); } + public int RefreshThrottling + { + get => _refreshThrottling; + set => SetProperty(ref _refreshThrottling, Math.Clamp(value, 0, 5000)); + } + + public bool ManualRefresh + { + get => _manualRefresh; + set => SetProperty(ref _manualRefresh, value); + } + public int SubjectGuideLength { get => _subjectGuideLength; @@ -801,6 +813,8 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private LayoutInfo _layout = new(); private int _maxHistoryCommits = 20000; + private int _refreshThrottling = 1000; + private bool _manualRefresh = false; private int _subjectGuideLength = 50; private bool _useFixedTabWidth = true; private bool _useAutoHideScrollBars = true; diff --git a/src/ViewModels/Repository.cs b/src/ViewModels/Repository.cs index 1ea5951a8..b03bf90d5 100644 --- a/src/ViewModels/Repository.cs +++ b/src/ViewModels/Repository.cs @@ -564,6 +564,7 @@ public async Task InstallLFSAsync() public async Task TrackLFSFileAsync(string pattern, bool isFilenameMode) { + using var lockWatcher = LockWatcher(); var log = CreateLog("Track LFS"); var succ = await new Commands.LFS(FullPath) .Use(log) @@ -1924,6 +1925,7 @@ private async Task AutoFetchOnUIThread() if (remotes.Count == 0) return; + using var lockWatcher = LockWatcher(); IsAutoFetching = true; log = CreateLog("Auto-Fetch"); diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 6b3aa863c..60109f0eb 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -47,7 +47,7 @@ - + - + + + + + + + + - - - - - - - + +