diff --git a/src/Models/Watcher.cs b/src/Models/Watcher.cs index 66e246bc0..2a0a2e38b 100644 --- a/src/Models/Watcher.cs +++ b/src/Models/Watcher.cs @@ -145,6 +145,11 @@ private void Tick(object sender) 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 +254,7 @@ private void OnRepositoryChanged(object o, FileSystemEventArgs e) /// private void OnWatcherError(object o, ErrorEventArgs e) { - var desired = DateTime.Now.AddSeconds(1).ToFileTime(); + var desired = RecordChange(); Interlocked.Exchange(ref _updateBranch, desired); Interlocked.Exchange(ref _updateTags, desired); Interlocked.Exchange(ref _updateStashes, desired); @@ -293,7 +298,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 +307,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,24 +323,24 @@ 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()); } } @@ -346,7 +351,7 @@ private void HandleWorkingCopyFileChanged(string name, string fullpath) 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 +360,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 +378,20 @@ 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 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..8d8274a7d 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -708,6 +708,7 @@ Enable compact folders in changes tree Language 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/Preferences.cs b/src/ViewModels/Preferences.cs index 9d9fb3f9f..6441ebd68 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,12 @@ public int MaxHistoryCommits set => SetProperty(ref _maxHistoryCommits, value); } + public int RefreshThrottling + { + get => _refreshThrottling; + set => SetProperty(ref _refreshThrottling, Math.Clamp(value, 0, 5000)); + } + public int SubjectGuideLength { get => _subjectGuideLength; @@ -801,6 +807,7 @@ private bool RemoveInvalidRepositoriesRecursive(List collection) private LayoutInfo _layout = new(); private int _maxHistoryCommits = 20000; + private int _refreshThrottling = 1000; private int _subjectGuideLength = 50; private bool _useFixedTabWidth = true; private bool _useAutoHideScrollBars = true; diff --git a/src/Views/Preferences.axaml b/src/Views/Preferences.axaml index 6b3aa863c..35694b04e 100644 --- a/src/Views/Preferences.axaml +++ b/src/Views/Preferences.axaml @@ -47,7 +47,7 @@ - + - + + + + + + + - - - - - - - typeof(Watcher).GetMethod(method, flags).Invoke(watcher, args); + void Tick() => Invoke("Tick", new object[] { null }); + void WorkingCopy() => Invoke("HandleWorkingCopyFileChanged", "output.dll", Path.Combine(root, "output.dll")); + void Git(string path) => Invoke("HandleGitDirFileChanged", path); + void Check(bool condition, string message) + { + if (!condition) + throw new Exception(message); + } + + SourceGit.ViewModels.Preferences.Instance.RefreshThrottling = 200; + Git("HEAD"); + Git("refs/tags/v1"); + Git("refs/stash"); + for (var i = 0; i < 6; i++) + { + WorkingCopy(); + Thread.Sleep(80); + Tick(); + Check(repo.Calls.Count == 0, "Continuous working-copy changes must delay every pending refresh."); + } + + Thread.Sleep(160); + Tick(); + Check(repo.Calls.SequenceEqual(new[] { "branches", "worktrees", "working-copy", "submodules", "stashes", "tags", "commits" }), + "A quiet period must refresh all pending categories once."); + repo.Calls.Clear(); + Tick(); + Check(repo.Calls.Count == 0, "Pending refreshes must be consumed."); + + WorkingCopy(); + Thread.Sleep(240); + Invoke("HandleWorkingCopyFileChanged", ".vs/cache", Path.Combine(root, ".vs", "cache")); + Git("index.lock"); + Tick(); + Check(repo.Calls.SequenceEqual(new[] { "working-copy" }), "Ignored paths must not restart the quiet period."); + repo.Calls.Clear(); + + SourceGit.ViewModels.Preferences.Instance.RefreshThrottling = 5000; + WorkingCopy(); + Tick(); + Check(repo.Calls.Count == 0, "The maximum setting must delay refreshes."); + typeof(Watcher).GetField("_lastChange", flags).SetValue(watcher, DateTime.Now.AddMilliseconds(-5100).ToFileTime()); + Tick(); + Check(repo.Calls.SequenceEqual(new[] { "working-copy" }), "Refresh must resume after the maximum quiet period."); + repo.Calls.Clear(); + + WorkingCopy(); + SourceGit.ViewModels.Preferences.Instance.RefreshThrottling = 0; + Tick(); + Check(repo.Calls.SequenceEqual(new[] { "working-copy" }), "Changing the setting to zero must release pending refreshes immediately."); + repo.Calls.Clear(); + + using (watcher.Lock()) + { + WorkingCopy(); + Tick(); + Check(repo.Calls.Count == 0, "Watcher locks must still suppress refreshes."); + } + Tick(); + Check(repo.Calls.SequenceEqual(new[] { "working-copy" }), "Unlocking must preserve pending refreshes."); + repo.Calls.Clear(); + + SourceGit.ViewModels.Preferences.Instance.RefreshThrottling = 200; + Invoke("OnWatcherError", null, new ErrorEventArgs(new InternalBufferOverflowException())); + Tick(); + Check(repo.Calls.Count == 0, "Watcher overflow recovery must respect the quiet period."); + Thread.Sleep(240); + Tick(); + Check(repo.Calls.Count == 7, "Watcher overflow recovery must reload all categories."); + Console.WriteLine("Watcher checks passed."); +} +finally +{ + Directory.Delete(root, true); +} + +sealed class TestRepository : IRepository +{ + public List Calls { get; } = new(); + public bool MayHaveSubmodules() => true; + public void RefreshBranches() => Calls.Add("branches"); + public void RefreshWorktrees() => Calls.Add("worktrees"); + public void RefreshTags() => Calls.Add("tags"); + public void RefreshCommits() => Calls.Add("commits"); + public void RefreshSubmodules() => Calls.Add("submodules"); + public void RefreshWorkingCopyChanges() => Calls.Add("working-copy"); + public void RefreshStashes() => Calls.Add("stashes"); +} + +namespace SourceGit.ViewModels +{ + // Keep these watcher checks independent of the desktop application and user settings. + public sealed class Preferences + { + public static Preferences Instance { get; } = new(); + public int RefreshThrottling { get; set; } = 1000; + } +} diff --git a/tests/WatcherChecks/WatcherChecks.csproj b/tests/WatcherChecks/WatcherChecks.csproj new file mode 100644 index 000000000..cc64995a4 --- /dev/null +++ b/tests/WatcherChecks/WatcherChecks.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + + + + + +