From 6b4796499cfd547dda58c6a663325769a09d525c Mon Sep 17 00:00:00 2001 From: Ryan Shepherd Date: Mon, 5 Aug 2024 23:05:10 -0700 Subject: [PATCH 1/6] Create ThrottledTask to throttle status updates in response to file changes --- .../Models/GitRepositoryStatus.cs | 94 ++++++ .../Models/GitStatusEntry.cs | 40 +++ .../Models/RepositoryWrapper.cs | 229 -------------- .../Models/StatusCache.cs | 288 ++++++++++++++++++ .../Models/ThrottledTask.cs | 49 +++ 5 files changed, 471 insertions(+), 229 deletions(-) create mode 100644 extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs create mode 100644 extensions/GitExtension/FileExplorerGitIntegration/Models/GitStatusEntry.cs create mode 100644 extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs create mode 100644 extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs new file mode 100644 index 0000000000..dfe0ca7c5f --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using LibGit2Sharp; + +namespace FileExplorerGitIntegration.Models; + +internal sealed class GitRepositoryStatus +{ + private readonly Dictionary _entries = new(); + private readonly List _added = new(); + private readonly List _staged = new(); + private readonly List _removed = new(); + private readonly List _untracked = new(); + private readonly List _modified = new(); + private readonly List _missing = new(); + private readonly List _ignored = new(); + private readonly List _renamedInIndex = new(); + private readonly List _renamedInWorkDir = new(); + private readonly List _conflicted = new(); + + public GitRepositoryStatus() + { + } + + public void Add(string path, GitStatusEntry status) + { + _entries.Add(path, status); + if (status.Status.HasFlag(FileStatus.NewInIndex)) + { + _added.Add(status); + } + + if (status.Status.HasFlag(FileStatus.ModifiedInIndex)) + { + _staged.Add(status); + } + + if (status.Status.HasFlag(FileStatus.DeletedFromIndex)) + { + _removed.Add(status); + } + + if (status.Status.HasFlag(FileStatus.NewInWorkdir)) + { + _untracked.Add(status); + } + + if (status.Status.HasFlag(FileStatus.ModifiedInWorkdir)) + { + _modified.Add(status); + } + + if (status.Status.HasFlag(FileStatus.DeletedFromWorkdir)) + { + _missing.Add(status); + } + + if (status.Status.HasFlag(FileStatus.RenamedInIndex)) + { + _renamedInIndex.Add(status); + } + + if (status.Status.HasFlag(FileStatus.RenamedInWorkdir)) + { + _renamedInWorkDir.Add(status); + } + + if (status.Status.HasFlag(FileStatus.Conflicted)) + { + _conflicted.Add(status); + } + } + + public Dictionary Entries => _entries; + + public List Added => _added; + + public List Staged => _staged; + + public List Removed => _removed; + + public List Untracked => _untracked; + + public List Modified => _modified; + + public List Missing => _missing; + + public List RenamedInIndex => _renamedInIndex; + + public List RenamedInWorkDir => _renamedInWorkDir; + + public List Conflicted => _conflicted; +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitStatusEntry.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitStatusEntry.cs new file mode 100644 index 0000000000..6e06262a62 --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitStatusEntry.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Globalization; +using LibGit2Sharp; + +namespace FileExplorerGitIntegration.Models; + +[DebuggerDisplay("{DebuggerDisplay,nq}")] +internal sealed class GitStatusEntry +{ + public GitStatusEntry(string path, FileStatus status, string? renameOldPath = null) + { + Path = path; + Status = status; + RenameOldPath = renameOldPath; + } + + public string Path { get; set; } + + public FileStatus Status { get; set; } + + public string? RenameOldPath { get; set; } + + public string? RenameNewPath { get; set; } + + private string DebuggerDisplay + { + get + { + if (Status.HasFlag(FileStatus.RenamedInIndex) || Status.HasFlag(FileStatus.RenamedInWorkdir)) + { + return string.Format(CultureInfo.InvariantCulture, "{0}: {1} -> {2}", Status, RenameOldPath, Path); + } + + return string.Format(CultureInfo.InvariantCulture, "{0}: {1}", Status, Path); + } + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs index 09527e79da..a510456dcf 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs @@ -2,8 +2,6 @@ // Licensed under the MIT License. using System.Collections.Concurrent; -using System.Diagnostics; -using System.Globalization; using System.Management.Automation; using LibGit2Sharp; using Microsoft.Windows.DevHome.SDK; @@ -70,237 +68,10 @@ public IEnumerable GetCommits() return _commits; } - [DebuggerDisplay("{DebuggerDisplay,nq}")] - internal sealed class GitStatusEntry - { - public GitStatusEntry(string path, FileStatus status, string? renameOldPath = null) - { - Path = path; - Status = status; - RenameOldPath = renameOldPath; - } - - public string Path { get; set; } - - public FileStatus Status { get; set; } - - public string? RenameOldPath { get; set; } - - public string? RenameNewPath { get; set; } - - private string DebuggerDisplay - { - get - { - if (Status.HasFlag(FileStatus.RenamedInIndex) || Status.HasFlag(FileStatus.RenamedInWorkdir)) - { - return string.Format(CultureInfo.InvariantCulture, "{0}: {1} -> {2}", Status, RenameOldPath, Path); - } - - return string.Format(CultureInfo.InvariantCulture, "{0}: {1}", Status, Path); - } - } - } - - internal sealed class GitRepositoryStatus - { - private readonly Dictionary _entries = new(); - private readonly List _added = new(); - private readonly List _staged = new(); - private readonly List _removed = new(); - private readonly List _untracked = new(); - private readonly List _modified = new(); - private readonly List _missing = new(); - private readonly List _renamedInIndex = new(); - private readonly List _renamedInWorkDir = new(); - private readonly List _conflicted = new(); - - public GitRepositoryStatus() - { - } - - public void Add(string path, GitStatusEntry status) - { - _entries.Add(path, status); - if (status.Status.HasFlag(FileStatus.NewInIndex)) - { - _added.Add(status); - } - - if (status.Status.HasFlag(FileStatus.ModifiedInIndex)) - { - _staged.Add(status); - } - - if (status.Status.HasFlag(FileStatus.DeletedFromIndex)) - { - _removed.Add(status); - } - - if (status.Status.HasFlag(FileStatus.NewInWorkdir)) - { - _untracked.Add(status); - } - - if (status.Status.HasFlag(FileStatus.ModifiedInWorkdir)) - { - _modified.Add(status); - } - - if (status.Status.HasFlag(FileStatus.DeletedFromWorkdir)) - { - _missing.Add(status); - } - - if (status.Status.HasFlag(FileStatus.RenamedInIndex)) - { - _renamedInIndex.Add(status); - } - - if (status.Status.HasFlag(FileStatus.RenamedInWorkdir)) - { - _renamedInWorkDir.Add(status); - } - - if (status.Status.HasFlag(FileStatus.Conflicted)) - { - _conflicted.Add(status); - } - } - - public Dictionary Entries => _entries; - - public List Added => _added; - - public List Staged => _staged; - - public List Removed => _removed; - - public List Untracked => _untracked; - - public List Modified => _modified; - - public List Missing => _missing; - - public List RenamedInIndex => _renamedInIndex; - - public List RenamedInWorkDir => _renamedInWorkDir; - - public List Conflicted => _conflicted; - } - public string GetRepoStatus() { var repoStatus = new GitRepositoryStatus(); - if (_gitInstalled) - { - // Options fully explained at https://git-scm.com/docs/git-status - // --no-optional-locks : Since this we are essentially running in the background, don't take any optional git locks - // that could interfere with the user's work. This means calling "status" won't auto-update the - // index to make future "status" calls faster, but it's better to be unintrusive. - // --porcelain=v2 : The v2 gives us nice detailed entries that help us separate ordinary changes from renames, conflicts, and untracked - // Disclaimer: I'm not sure how far back porcelain=v2 is supported, but I'm pretty sure it's at least 3-4 years. - // There could be old Git installations that predate it. - // -z : Terminate filenames and entries with NUL instead of space/LF. This helps us deal with filenames containing spaces. - var result = GitExecute.ExecuteGitCommand(_gitDetect.GitConfiguration.ReadInstallPath(), _workingDirectory, "--no-optional-locks status --porcelain=v2 -z"); - if (result.Status == ProviderOperationStatus.Success && result.Output != null) - { - var parts = result.Output.Split('\0', StringSplitOptions.RemoveEmptyEntries); - for (var i = 0; i < parts.Length; ++i) - { - var line = parts[i]; - if (line.StartsWith("1 ", StringComparison.Ordinal)) - { - // For porcelain=v2, "ordinary" entries have the following format: - // 1 - // For now, we only care about the and fields. - var pieces = line.Split(' ', 9); - var fileStatusString = pieces[1]; - var filePath = pieces[8]; - FileStatus statusEntry = FileStatus.Unaltered; - switch (fileStatusString[0]) - { - case 'M': - statusEntry |= FileStatus.ModifiedInIndex; - break; - - case 'T': - statusEntry |= FileStatus.TypeChangeInIndex; - break; - - case 'A': - statusEntry |= FileStatus.NewInIndex; - break; - - case 'D': - statusEntry |= FileStatus.DeletedFromIndex; - break; - } - - switch (fileStatusString[1]) - { - case 'M': - statusEntry |= FileStatus.ModifiedInWorkdir; - break; - - case 'T': - statusEntry |= FileStatus.TypeChangeInWorkdir; - break; - - case 'A': - statusEntry |= FileStatus.NewInWorkdir; - break; - - case 'D': - statusEntry |= FileStatus.DeletedFromWorkdir; - break; - } - - repoStatus.Add(filePath, new GitStatusEntry(filePath, statusEntry)); - } - else if (line.StartsWith("2 ", StringComparison.Ordinal)) - { - // For porcelain=v2, "rename" entries have the following format: - // 2 - // For now, we only care about the , , and fields. - var pieces = line.Split(' ', 9); - var fileStatusString = pieces[1]; - var newPath = pieces[8]; - var oldPath = parts[++i]; - FileStatus statusEntry = FileStatus.Unaltered; - if (fileStatusString[0] == 'R') - { - statusEntry |= FileStatus.RenamedInIndex; - } - - if (fileStatusString[1] == 'R') - { - statusEntry |= FileStatus.RenamedInWorkdir; - } - - repoStatus.Add(newPath, new GitStatusEntry(newPath, statusEntry, oldPath)); - } - else if (line.StartsWith("u ", StringComparison.Ordinal)) - { - // For porcelain=v2, "unmerged" entries have the following format: - // u

- // For now, we only care about the . (We only say that the file has a conflict, not the details) - var pieces = line.Split(' ', 11); - var filePath = pieces[10]; - repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.Conflicted)); - } - else if (line.StartsWith("? ", StringComparison.Ordinal)) - { - // For porcelain=v2, "untracked" entries have the following format: - // ? - // For now, we only care about the . - var filePath = line.Substring(2); - repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.NewInWorkdir)); - } - } - } - } string branchName; var branchStatus = string.Empty; diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs new file mode 100644 index 0000000000..c8575b77c5 --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace FileExplorerGitIntegration.Models; + +using System.Data; +using System.Diagnostics; +using System.Threading.Channels; +using LibGit2Sharp; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Windows.DevHome.SDK; + +// Caches the most recently obtained repo status. +// Use FileSystemWatcher to invalidate the cache. +// File-based invalidation can come in swarms. For example, building a project, changing/pulling branches. +// To avoid flooding with status retrievals, we "debounce" the invalidations +internal sealed class StatusCache : IDisposable +{ + private readonly string _workingDirectory; + + private readonly FileSystemWatcher _watcher; + private readonly ThrottledTask _throttledUpdate; + + private readonly ReaderWriterLockSlim _statusLock = new(); + + private readonly GitDetect _gitDetect = new(); + private readonly bool _gitInstalled; + + private GitRepositoryStatus? _status; + + private bool _disposedValue; + + public StatusCache(string rootFolder) + { + _workingDirectory = rootFolder; + _throttledUpdate = new ThrottledTask( + () => + { + UpdateStatus(RetrieveStatus()); + }, + TimeSpan.FromSeconds(3)); + + _gitInstalled = _gitDetect.DetectGit(); + + _watcher = new FileSystemWatcher(rootFolder) + { + NotifyFilter = NotifyFilters.Attributes + | NotifyFilters.CreationTime + | NotifyFilters.DirectoryName + | NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + IncludeSubdirectories = true, + }; + _watcher.Error += OnError; + _watcher.Changed += OnChanged; + _watcher.Created += OnChanged; + _watcher.Deleted += OnChanged; + _watcher.Renamed += OnRenamed; + + _watcher.EnableRaisingEvents = true; + } + + private void OnChanged(object sender, FileSystemEventArgs e) + { + if (ShouldIgnore(e.Name)) + { + return; + } + + Invalidate(); + } + + private void OnError(object sender, ErrorEventArgs e) + { + Invalidate(); + } + + private void OnRenamed(object sender, RenamedEventArgs e) + { + Invalidate(); + } + + private bool ShouldIgnore(string? relativePath) + { + if (relativePath == null) + { + return true; + } + + var filename = Path.GetFileName(relativePath); + if (filename == null || filename == "index.lock" || filename == ".git") + { + return true; + } + + if (relativePath.StartsWith(".git", StringComparison.OrdinalIgnoreCase) && relativePath.EndsWith(".lock", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + public GitRepositoryStatus Status + { + get + { + _statusLock.EnterReadLock(); + if (_status != null) + { + var result = _status; + _statusLock.ExitReadLock(); + return result; + } + + // Populate initial status + _statusLock.EnterWriteLock(); + try + { + _status ??= RetrieveStatus(); + return _status; + } + finally + { + _statusLock.ExitWriteLock(); + } + } + } + + private void UpdateStatus(GitRepositoryStatus newStatus) + { + GitRepositoryStatus? oldStatus; + _statusLock.EnterWriteLock(); + try + { + oldStatus = _status; + _status = newStatus; + } + finally + { + _statusLock.ExitWriteLock(); + } + + // Diff old and new status to obtain a list of files to refresh to the Shell. + } + + private GitRepositoryStatus RetrieveStatus() + { + var repoStatus = new GitRepositoryStatus(); + if (_gitInstalled) + { + // Options fully explained at https://git-scm.com/docs/git-status + // --no-optional-locks : Since this we are essentially running in the background, don't take any optional git locks + // that could interfere with the user's work. This means calling "status" won't auto-update the + // index to make future "status" calls faster, but it's better to be unintrusive. + // --porcelain=v2 : The v2 gives us nice detailed entries that help us separate ordinary changes from renames, conflicts, and untracked + // Disclaimer: I'm not sure how far back porcelain=v2 is supported, but I'm pretty sure it's at least 3-4 years. + // There could be old Git installations that predate it. + // -z : Terminate filenames and entries with NUL instead of space/LF. This helps us deal with filenames containing spaces. + var result = GitExecute.ExecuteGitCommand(_gitDetect.GitConfiguration.ReadInstallPath(), _workingDirectory, "--no-optional-locks status --porcelain=v2 -z"); + if (result.Status == ProviderOperationStatus.Success && result.Output != null) + { + var parts = result.Output.Split('\0', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < parts.Length; ++i) + { + var line = parts[i]; + if (line.StartsWith("1 ", StringComparison.Ordinal)) + { + // For porcelain=v2, "ordinary" entries have the following format: + // 1 + // For now, we only care about the and fields. + var pieces = line.Split(' ', 9); + var fileStatusString = pieces[1]; + var filePath = pieces[8]; + FileStatus statusEntry = FileStatus.Unaltered; + switch (fileStatusString[0]) + { + case 'M': + statusEntry |= FileStatus.ModifiedInIndex; + break; + + case 'T': + statusEntry |= FileStatus.TypeChangeInIndex; + break; + + case 'A': + statusEntry |= FileStatus.NewInIndex; + break; + + case 'D': + statusEntry |= FileStatus.DeletedFromIndex; + break; + } + + switch (fileStatusString[1]) + { + case 'M': + statusEntry |= FileStatus.ModifiedInWorkdir; + break; + + case 'T': + statusEntry |= FileStatus.TypeChangeInWorkdir; + break; + + case 'A': + statusEntry |= FileStatus.NewInWorkdir; + break; + + case 'D': + statusEntry |= FileStatus.DeletedFromWorkdir; + break; + } + + repoStatus.Add(filePath, new GitStatusEntry(filePath, statusEntry)); + } + else if (line.StartsWith("2 ", StringComparison.Ordinal)) + { + // For porcelain=v2, "rename" entries have the following format: + // 2 + // For now, we only care about the , , and fields. + var pieces = line.Split(' ', 9); + var fileStatusString = pieces[1]; + var newPath = pieces[8]; + var oldPath = parts[++i]; + FileStatus statusEntry = FileStatus.Unaltered; + if (fileStatusString[0] == 'R') + { + statusEntry |= FileStatus.RenamedInIndex; + } + + if (fileStatusString[1] == 'R') + { + statusEntry |= FileStatus.RenamedInWorkdir; + } + + repoStatus.Add(newPath, new GitStatusEntry(newPath, statusEntry, oldPath)); + } + else if (line.StartsWith("u ", StringComparison.Ordinal)) + { + // For porcelain=v2, "unmerged" entries have the following format: + // u

+ // For now, we only care about the . (We only say that the file has a conflict, not the details) + var pieces = line.Split(' ', 11); + var filePath = pieces[10]; + repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.Conflicted)); + } + else if (line.StartsWith("? ", StringComparison.Ordinal)) + { + // For porcelain=v2, "untracked" entries have the following format: + // ? + // For now, we only care about the . + var filePath = line.Substring(2); + repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.NewInWorkdir)); + } + } + } + } + + return repoStatus; + } + + private void Invalidate() + { + _throttledUpdate.Run(); + } + + internal void Dispose(bool disposing) + { + if (!_disposedValue) + { + if (disposing) + { + _watcher.Dispose(); + _statusLock.Dispose(); + } + } + + _disposedValue = true; + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } +} diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs new file mode 100644 index 0000000000..23742cebae --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace FileExplorerGitIntegration.Models; + +internal class ThrottledTask +{ + private readonly TimeSpan _interval; + private readonly object _lock = new(); + private readonly Stopwatch _stopwatch = new(); + + private readonly Action _action; + + private Task? _currentTask; + private bool _running; + + private bool Throttled => _stopwatch.Elapsed < _interval; + + public ThrottledTask(Action action, TimeSpan interval) + { + _action = action; + _interval = interval; + } + + public void Run(CancellationToken cancellationToken = default) + { + lock (_lock) + { + if (_currentTask != null && (_running || Throttled)) + { + return; + } + + _running = true; + _currentTask = Task.Run(_action, cancellationToken); + _currentTask.ContinueWith( + task => + { + _stopwatch.Restart(); + _running = false; + }, + cancellationToken); + + return; + } + } +} From ece3aa2060b81d553aef22db4143116779a4f324 Mon Sep 17 00:00:00 2001 From: Ryan Shepherd Date: Mon, 5 Aug 2024 23:18:25 -0700 Subject: [PATCH 2/6] Small fixes --- .../Models/RepositoryWrapper.cs | 8 +++----- .../FileExplorerGitIntegration/Models/StatusCache.cs | 1 + .../FileExplorerGitIntegration/Models/ThrottledTask.cs | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs index a510456dcf..3ef87995e7 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs @@ -16,8 +16,7 @@ internal sealed class RepositoryWrapper : IDisposable private readonly string _workingDirectory; - private readonly GitDetect _gitDetect = new(); - private readonly bool _gitInstalled; + private readonly StatusCache _statusCache; private Commit? _head; private CommitLogCache? _commits; @@ -28,7 +27,7 @@ public RepositoryWrapper(string rootFolder) { _repo = new Repository(rootFolder); _workingDirectory = _repo.Info.WorkingDirectory; - _gitInstalled = _gitDetect.DetectGit(); + _statusCache = new StatusCache(rootFolder); } public IEnumerable GetCommits() @@ -70,8 +69,7 @@ public IEnumerable GetCommits() public string GetRepoStatus() { - var repoStatus = new GitRepositoryStatus(); - + var repoStatus = _statusCache.Status; string branchName; var branchStatus = string.Empty; diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs index c8575b77c5..aded72847a 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs @@ -115,6 +115,7 @@ public GitRepositoryStatus Status } // Populate initial status + _statusLock.ExitReadLock(); _statusLock.EnterWriteLock(); try { diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs index 23742cebae..2a5da6a6ab 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs @@ -5,7 +5,7 @@ namespace FileExplorerGitIntegration.Models; -internal class ThrottledTask +internal sealed class ThrottledTask { private readonly TimeSpan _interval; private readonly object _lock = new(); From 2e4c5f5d48ae6b6062f2f98cf00b4ac007a7bd5a Mon Sep 17 00:00:00 2001 From: Ryan Shepherd Date: Mon, 5 Aug 2024 23:36:16 -0700 Subject: [PATCH 3/6] Use git status for files --- .../Models/RepositoryWrapper.cs | 21 +++++++------------ .../Models/StatusCache.cs | 3 +-- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs index 3ef87995e7..ec5d47a5b2 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs @@ -125,37 +125,32 @@ public string GetFileStatus(string relativePath) return string.Empty; } - FileStatus status; - try + GitStatusEntry? status; + if (!_statusCache.Status.Entries.TryGetValue(relativePath, out status)) { - _repoLock.EnterWriteLock(); - status = _repo.RetrieveStatus(relativePath); - } - finally - { - _repoLock.ExitWriteLock(); + return string.Empty; } - if (status == FileStatus.Unaltered || status.HasFlag(FileStatus.Nonexistent | FileStatus.Ignored)) + if (status.Status == FileStatus.Unaltered || status.Status.HasFlag(FileStatus.Nonexistent | FileStatus.Ignored)) { return string.Empty; } - else if (status.HasFlag(FileStatus.Conflicted)) + else if (status.Status.HasFlag(FileStatus.Conflicted)) { return "Merge conflict"; } - else if (status.HasFlag(FileStatus.NewInWorkdir)) + else if (status.Status.HasFlag(FileStatus.NewInWorkdir)) { return "Untracked"; } var statusString = string.Empty; - if (status.HasFlag(FileStatus.NewInIndex) || status.HasFlag(FileStatus.ModifiedInIndex) || status.HasFlag(FileStatus.RenamedInIndex) || status.HasFlag(FileStatus.TypeChangeInIndex)) + if (status.Status.HasFlag(FileStatus.NewInIndex) || status.Status.HasFlag(FileStatus.ModifiedInIndex) || status.Status.HasFlag(FileStatus.RenamedInIndex) || status.Status.HasFlag(FileStatus.TypeChangeInIndex)) { statusString = "Staged"; } - if (status.HasFlag(FileStatus.ModifiedInWorkdir) || status.HasFlag(FileStatus.RenamedInWorkdir) || status.HasFlag(FileStatus.TypeChangeInWorkdir)) + if (status.Status.HasFlag(FileStatus.ModifiedInWorkdir) || status.Status.HasFlag(FileStatus.RenamedInWorkdir) || status.Status.HasFlag(FileStatus.TypeChangeInWorkdir)) { if (string.IsNullOrEmpty(statusString)) { diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs index aded72847a..ea806d73db 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs @@ -44,8 +44,7 @@ public StatusCache(string rootFolder) _watcher = new FileSystemWatcher(rootFolder) { - NotifyFilter = NotifyFilters.Attributes - | NotifyFilters.CreationTime + NotifyFilter = NotifyFilters.CreationTime | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.LastWrite From 1aed2e96eb085287866e0ccb0a12233e3ab6f354 Mon Sep 17 00:00:00 2001 From: Ryan Shepherd Date: Tue, 6 Aug 2024 00:13:23 -0700 Subject: [PATCH 4/6] Notify Shell of file status changes --- .../Models/StatusCache.cs | 41 +++++++++++++++++++ .../NativeMethods.txt | 3 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs index ea806d73db..2b0fe58edc 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs @@ -5,10 +5,12 @@ namespace FileExplorerGitIntegration.Models; using System.Data; using System.Diagnostics; +using System.Runtime.InteropServices; using System.Threading.Channels; using LibGit2Sharp; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Windows.DevHome.SDK; +using Windows.Win32; // Caches the most recently obtained repo status. // Use FileSystemWatcher to invalidate the cache. @@ -143,6 +145,45 @@ private void UpdateStatus(GitRepositoryStatus newStatus) } // Diff old and new status to obtain a list of files to refresh to the Shell. + if (oldStatus == null) + { + return; + } + + HashSet changed = []; + foreach (var newEntry in newStatus.Entries) + { + GitStatusEntry? oldValue; + if (oldStatus.Entries.TryGetValue(newEntry.Key, out oldValue)) + { + if (newEntry.Value.Status != oldValue.Status) + { + changed.Add(newEntry.Key); + } + + oldStatus.Entries.Remove(newEntry.Key); + } + else + { + changed.Add(newEntry.Key); + } + } + + foreach (var oldEntry in oldStatus.Entries) + { + changed.Add(oldEntry.Key); + } + + foreach (var entry in changed) + { + var fixedPath = Path.Combine(_workingDirectory, entry).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + unsafe + { + IntPtr strPtr = Marshal.StringToCoTaskMemUni(fixedPath); + PInvoke.SHChangeNotify(Windows.Win32.UI.Shell.SHCNE_ID.SHCNE_UPDATEITEM, Windows.Win32.UI.Shell.SHCNF_FLAGS.SHCNF_PATH, (void*)strPtr, null); + Marshal.FreeCoTaskMem(strPtr); + } + } } private GitRepositoryStatus RetrieveStatus() diff --git a/extensions/GitExtension/FileExplorerGitIntegration/NativeMethods.txt b/extensions/GitExtension/FileExplorerGitIntegration/NativeMethods.txt index 2bdb403f49..f21b5ac4a8 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/NativeMethods.txt +++ b/extensions/GitExtension/FileExplorerGitIntegration/NativeMethods.txt @@ -2,4 +2,5 @@ CoRevokeClassObject CoResumeClassObjects MEMORYSTATUSEX -GlobalMemoryStatusEx \ No newline at end of file +GlobalMemoryStatusEx +SHChangeNotify From 5b44dadefda442589f23c45cba9d6877b9a9c626 Mon Sep 17 00:00:00 2001 From: Ryan Shepherd Date: Tue, 6 Aug 2024 15:24:09 -0700 Subject: [PATCH 5/6] Loop while queuing, simplify --- .../Models/ThrottledTask.cs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs index 2a5da6a6ab..e44c695b5a 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs @@ -9,14 +9,11 @@ internal sealed class ThrottledTask { private readonly TimeSpan _interval; private readonly object _lock = new(); - private readonly Stopwatch _stopwatch = new(); private readonly Action _action; private Task? _currentTask; - private bool _running; - - private bool Throttled => _stopwatch.Elapsed < _interval; + private bool _shouldQueue; public ThrottledTask(Action action, TimeSpan interval) { @@ -24,26 +21,40 @@ public ThrottledTask(Action action, TimeSpan interval) _interval = interval; } + // When the action completes, wait for interval before checking if a new action has been queued. public void Run(CancellationToken cancellationToken = default) { lock (_lock) { - if (_currentTask != null && (_running || Throttled)) + if (_currentTask != null && !_currentTask.IsCompleted) { + _shouldQueue = true; return; } - _running = true; - _currentTask = Task.Run(_action, cancellationToken); - _currentTask.ContinueWith( - task => - { - _stopwatch.Restart(); - _running = false; - }, + _currentTask = Task.Run( + async () => + { + bool shouldContinue = true; + while (shouldContinue) + { + _action.Invoke(); + await Task.Delay(_interval, cancellationToken); + lock (_lock) + { + if (_shouldQueue) + { + _shouldQueue = false; + } + else + { + shouldContinue = false; + _currentTask = null; + } + } + } + }, cancellationToken); - - return; } } } From 6fe48388d249b5dbdcec79c492ead784ece79a68 Mon Sep 17 00:00:00 2001 From: Ryan Shepherd Date: Wed, 7 Aug 2024 12:31:07 -0700 Subject: [PATCH 6/6] PR feedback, some simplification and comments --- .../Models/GitRepositoryStatus.cs | 81 +++---- .../Models/RepositoryWrapper.cs | 2 +- .../Models/StatusCache.cs | 220 +++++++++--------- .../Models/ThrottledTask.cs | 15 +- 4 files changed, 152 insertions(+), 166 deletions(-) diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs index dfe0ca7c5f..890f72ca2b 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs @@ -7,7 +7,7 @@ namespace FileExplorerGitIntegration.Models; internal sealed class GitRepositoryStatus { - private readonly Dictionary _entries = new(); + private readonly Dictionary _fileEntries = new(); private readonly List _added = new(); private readonly List _staged = new(); private readonly List _removed = new(); @@ -18,77 +18,50 @@ internal sealed class GitRepositoryStatus private readonly List _renamedInIndex = new(); private readonly List _renamedInWorkDir = new(); private readonly List _conflicted = new(); + private readonly Dictionary> _statusEntries = new(); public GitRepositoryStatus() { + _statusEntries.Add(FileStatus.NewInIndex, new List()); + _statusEntries.Add(FileStatus.ModifiedInIndex, new List()); + _statusEntries.Add(FileStatus.DeletedFromIndex, new List()); + _statusEntries.Add(FileStatus.NewInWorkdir, new List()); + _statusEntries.Add(FileStatus.ModifiedInWorkdir, new List()); + _statusEntries.Add(FileStatus.DeletedFromWorkdir, new List()); + _statusEntries.Add(FileStatus.RenamedInIndex, new List()); + _statusEntries.Add(FileStatus.RenamedInWorkdir, new List()); + _statusEntries.Add(FileStatus.Conflicted, new List()); } public void Add(string path, GitStatusEntry status) { - _entries.Add(path, status); - if (status.Status.HasFlag(FileStatus.NewInIndex)) + _fileEntries.Add(path, status); + foreach (var entry in _statusEntries) { - _added.Add(status); - } - - if (status.Status.HasFlag(FileStatus.ModifiedInIndex)) - { - _staged.Add(status); - } - - if (status.Status.HasFlag(FileStatus.DeletedFromIndex)) - { - _removed.Add(status); - } - - if (status.Status.HasFlag(FileStatus.NewInWorkdir)) - { - _untracked.Add(status); - } - - if (status.Status.HasFlag(FileStatus.ModifiedInWorkdir)) - { - _modified.Add(status); - } - - if (status.Status.HasFlag(FileStatus.DeletedFromWorkdir)) - { - _missing.Add(status); - } - - if (status.Status.HasFlag(FileStatus.RenamedInIndex)) - { - _renamedInIndex.Add(status); - } - - if (status.Status.HasFlag(FileStatus.RenamedInWorkdir)) - { - _renamedInWorkDir.Add(status); - } - - if (status.Status.HasFlag(FileStatus.Conflicted)) - { - _conflicted.Add(status); + if (status.Status.HasFlag(entry.Key)) + { + entry.Value.Add(status); + } } } - public Dictionary Entries => _entries; + public Dictionary FileEntries => _fileEntries; - public List Added => _added; + public List Added => _statusEntries[FileStatus.NewInIndex]; - public List Staged => _staged; + public List Staged => _statusEntries[FileStatus.ModifiedInIndex]; - public List Removed => _removed; + public List Removed => _statusEntries[FileStatus.DeletedFromIndex]; - public List Untracked => _untracked; + public List Untracked => _statusEntries[FileStatus.NewInWorkdir]; - public List Modified => _modified; + public List Modified => _statusEntries[FileStatus.ModifiedInWorkdir]; - public List Missing => _missing; + public List Missing => _statusEntries[FileStatus.DeletedFromWorkdir]; - public List RenamedInIndex => _renamedInIndex; + public List RenamedInIndex => _statusEntries[FileStatus.RenamedInIndex]; - public List RenamedInWorkDir => _renamedInWorkDir; + public List RenamedInWorkDir => _statusEntries[FileStatus.RenamedInWorkdir]; - public List Conflicted => _conflicted; + public List Conflicted => _statusEntries[FileStatus.Conflicted]; } diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs index ec5d47a5b2..83d92b6907 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/RepositoryWrapper.cs @@ -126,7 +126,7 @@ public string GetFileStatus(string relativePath) } GitStatusEntry? status; - if (!_statusCache.Status.Entries.TryGetValue(relativePath, out status)) + if (!_statusCache.Status.FileEntries.TryGetValue(relativePath, out status)) { return string.Empty; } diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs index 2b0fe58edc..d9db6314f7 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs @@ -19,17 +19,13 @@ namespace FileExplorerGitIntegration.Models; internal sealed class StatusCache : IDisposable { private readonly string _workingDirectory; - private readonly FileSystemWatcher _watcher; private readonly ThrottledTask _throttledUpdate; - private readonly ReaderWriterLockSlim _statusLock = new(); - private readonly GitDetect _gitDetect = new(); private readonly bool _gitInstalled; private GitRepositoryStatus? _status; - private bool _disposedValue; public StatusCache(string rootFolder) @@ -151,17 +147,17 @@ private void UpdateStatus(GitRepositoryStatus newStatus) } HashSet changed = []; - foreach (var newEntry in newStatus.Entries) + foreach (var newEntry in newStatus.FileEntries) { GitStatusEntry? oldValue; - if (oldStatus.Entries.TryGetValue(newEntry.Key, out oldValue)) + if (oldStatus.FileEntries.TryGetValue(newEntry.Key, out oldValue)) { if (newEntry.Value.Status != oldValue.Status) { changed.Add(newEntry.Key); } - oldStatus.Entries.Remove(newEntry.Key); + oldStatus.FileEntries.Remove(newEntry.Key); } else { @@ -169,7 +165,7 @@ private void UpdateStatus(GitRepositoryStatus newStatus) } } - foreach (var oldEntry in oldStatus.Entries) + foreach (var oldEntry in oldStatus.FileEntries) { changed.Add(oldEntry.Key); } @@ -189,112 +185,116 @@ private void UpdateStatus(GitRepositoryStatus newStatus) private GitRepositoryStatus RetrieveStatus() { var repoStatus = new GitRepositoryStatus(); - if (_gitInstalled) + if (!_gitInstalled) + { + return repoStatus; + } + + // Options fully explained at https://git-scm.com/docs/git-status + // --no-optional-locks : Since this we are essentially running in the background, don't take any optional git locks + // that could interfere with the user's work. This means calling "status" won't auto-update the + // index to make future "status" calls faster, but it's better to be unintrusive. + // --porcelain=v2 : The v2 gives us nice detailed entries that help us separate ordinary changes from renames, conflicts, and untracked + // Disclaimer: I'm not sure how far back porcelain=v2 is supported, but I'm pretty sure it's at least 3-4 years. + // There could be old Git installations that predate it. + // -z : Terminate filenames and entries with NUL instead of space/LF. This helps us deal with filenames containing spaces. + var result = GitExecute.ExecuteGitCommand(_gitDetect.GitConfiguration.ReadInstallPath(), _workingDirectory, "--no-optional-locks status --porcelain=v2 -z"); + if (result.Status != ProviderOperationStatus.Success || result.Output == null) + { + return repoStatus; + } + + var parts = result.Output.Split('\0', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < parts.Length; ++i) { - // Options fully explained at https://git-scm.com/docs/git-status - // --no-optional-locks : Since this we are essentially running in the background, don't take any optional git locks - // that could interfere with the user's work. This means calling "status" won't auto-update the - // index to make future "status" calls faster, but it's better to be unintrusive. - // --porcelain=v2 : The v2 gives us nice detailed entries that help us separate ordinary changes from renames, conflicts, and untracked - // Disclaimer: I'm not sure how far back porcelain=v2 is supported, but I'm pretty sure it's at least 3-4 years. - // There could be old Git installations that predate it. - // -z : Terminate filenames and entries with NUL instead of space/LF. This helps us deal with filenames containing spaces. - var result = GitExecute.ExecuteGitCommand(_gitDetect.GitConfiguration.ReadInstallPath(), _workingDirectory, "--no-optional-locks status --porcelain=v2 -z"); - if (result.Status == ProviderOperationStatus.Success && result.Output != null) + var line = parts[i]; + if (line.StartsWith("1 ", StringComparison.Ordinal)) + { + // For porcelain=v2, "ordinary" entries have the following format: + // 1 + // For now, we only care about the and fields. + var pieces = line.Split(' ', 9); + var fileStatusString = pieces[1]; + var filePath = pieces[8]; + FileStatus statusEntry = FileStatus.Unaltered; + switch (fileStatusString[0]) + { + case 'M': + statusEntry |= FileStatus.ModifiedInIndex; + break; + + case 'T': + statusEntry |= FileStatus.TypeChangeInIndex; + break; + + case 'A': + statusEntry |= FileStatus.NewInIndex; + break; + + case 'D': + statusEntry |= FileStatus.DeletedFromIndex; + break; + } + + switch (fileStatusString[1]) + { + case 'M': + statusEntry |= FileStatus.ModifiedInWorkdir; + break; + + case 'T': + statusEntry |= FileStatus.TypeChangeInWorkdir; + break; + + case 'A': + statusEntry |= FileStatus.NewInWorkdir; + break; + + case 'D': + statusEntry |= FileStatus.DeletedFromWorkdir; + break; + } + + repoStatus.Add(filePath, new GitStatusEntry(filePath, statusEntry)); + } + else if (line.StartsWith("2 ", StringComparison.Ordinal)) { - var parts = result.Output.Split('\0', StringSplitOptions.RemoveEmptyEntries); - for (var i = 0; i < parts.Length; ++i) + // For porcelain=v2, "rename" entries have the following format: + // 2 + // For now, we only care about the , , and fields. + var pieces = line.Split(' ', 9); + var fileStatusString = pieces[1]; + var newPath = pieces[8]; + var oldPath = parts[++i]; + FileStatus statusEntry = FileStatus.Unaltered; + if (fileStatusString[0] == 'R') { - var line = parts[i]; - if (line.StartsWith("1 ", StringComparison.Ordinal)) - { - // For porcelain=v2, "ordinary" entries have the following format: - // 1 - // For now, we only care about the and fields. - var pieces = line.Split(' ', 9); - var fileStatusString = pieces[1]; - var filePath = pieces[8]; - FileStatus statusEntry = FileStatus.Unaltered; - switch (fileStatusString[0]) - { - case 'M': - statusEntry |= FileStatus.ModifiedInIndex; - break; - - case 'T': - statusEntry |= FileStatus.TypeChangeInIndex; - break; - - case 'A': - statusEntry |= FileStatus.NewInIndex; - break; - - case 'D': - statusEntry |= FileStatus.DeletedFromIndex; - break; - } - - switch (fileStatusString[1]) - { - case 'M': - statusEntry |= FileStatus.ModifiedInWorkdir; - break; - - case 'T': - statusEntry |= FileStatus.TypeChangeInWorkdir; - break; - - case 'A': - statusEntry |= FileStatus.NewInWorkdir; - break; - - case 'D': - statusEntry |= FileStatus.DeletedFromWorkdir; - break; - } - - repoStatus.Add(filePath, new GitStatusEntry(filePath, statusEntry)); - } - else if (line.StartsWith("2 ", StringComparison.Ordinal)) - { - // For porcelain=v2, "rename" entries have the following format: - // 2 - // For now, we only care about the , , and fields. - var pieces = line.Split(' ', 9); - var fileStatusString = pieces[1]; - var newPath = pieces[8]; - var oldPath = parts[++i]; - FileStatus statusEntry = FileStatus.Unaltered; - if (fileStatusString[0] == 'R') - { - statusEntry |= FileStatus.RenamedInIndex; - } - - if (fileStatusString[1] == 'R') - { - statusEntry |= FileStatus.RenamedInWorkdir; - } - - repoStatus.Add(newPath, new GitStatusEntry(newPath, statusEntry, oldPath)); - } - else if (line.StartsWith("u ", StringComparison.Ordinal)) - { - // For porcelain=v2, "unmerged" entries have the following format: - // u

- // For now, we only care about the . (We only say that the file has a conflict, not the details) - var pieces = line.Split(' ', 11); - var filePath = pieces[10]; - repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.Conflicted)); - } - else if (line.StartsWith("? ", StringComparison.Ordinal)) - { - // For porcelain=v2, "untracked" entries have the following format: - // ? - // For now, we only care about the . - var filePath = line.Substring(2); - repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.NewInWorkdir)); - } + statusEntry |= FileStatus.RenamedInIndex; } + + if (fileStatusString[1] == 'R') + { + statusEntry |= FileStatus.RenamedInWorkdir; + } + + repoStatus.Add(newPath, new GitStatusEntry(newPath, statusEntry, oldPath)); + } + else if (line.StartsWith("u ", StringComparison.Ordinal)) + { + // For porcelain=v2, "unmerged" entries have the following format: + // u

+ // For now, we only care about the . (We only say that the file has a conflict, not the details) + var pieces = line.Split(' ', 11); + var filePath = pieces[10]; + repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.Conflicted)); + } + else if (line.StartsWith("? ", StringComparison.Ordinal)) + { + // For porcelain=v2, "untracked" entries have the following format: + // ? + // For now, we only care about the . + var filePath = line.Substring(2); + repoStatus.Add(filePath, new GitStatusEntry(filePath, FileStatus.NewInWorkdir)); } } diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs index e44c695b5a..a06d04bf4d 100644 --- a/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs @@ -15,13 +15,26 @@ internal sealed class ThrottledTask private Task? _currentTask; private bool _shouldQueue; + // Run an action, but ensure that `interval` time has elapsed after the last action completed before running again. + // If a task is already running when Run is called again, we "queue" that request to execute after enough time has passed. + // Multiple requests during this period of time result in only a single action being run after the waiting period. + // In other words, when there is a rapid flood of calls to Run(), this is coalesced into: + // - The first call to Run invokes _action immediately. + // - The subsequent calls within the window are all coalesced into a second, delayed invoke of _action + // - If more calls arrive during this second invoke, they are coalesced into a third, delayed invoke. + // - and so on... public ThrottledTask(Action action, TimeSpan interval) { _action = action; _interval = interval; } - // When the action completes, wait for interval before checking if a new action has been queued. + // The first time Run is called, create a task to invoke _action, and then delay for _interval as a "cooldown". + // If Run is not called again while the task is active (during the action or cooldown) + // then the task exits normally and resets state back to initial. + // Otherwise, if Run is called again while the task is active, + // then set _shouldQueue to true. + // Now, when the action and cooldown complete, we'll loopback and execute one more call and reset the queue flag. public void Run(CancellationToken cancellationToken = default) { lock (_lock)