diff --git a/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs new file mode 100644 index 0000000000..890f72ca2b --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/GitRepositoryStatus.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using LibGit2Sharp; + +namespace FileExplorerGitIntegration.Models; + +internal sealed class GitRepositoryStatus +{ + private readonly Dictionary _fileEntries = 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(); + 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) + { + _fileEntries.Add(path, status); + foreach (var entry in _statusEntries) + { + if (status.Status.HasFlag(entry.Key)) + { + entry.Value.Add(status); + } + } + } + + public Dictionary FileEntries => _fileEntries; + + public List Added => _statusEntries[FileStatus.NewInIndex]; + + public List Staged => _statusEntries[FileStatus.ModifiedInIndex]; + + public List Removed => _statusEntries[FileStatus.DeletedFromIndex]; + + public List Untracked => _statusEntries[FileStatus.NewInWorkdir]; + + public List Modified => _statusEntries[FileStatus.ModifiedInWorkdir]; + + public List Missing => _statusEntries[FileStatus.DeletedFromWorkdir]; + + public List RenamedInIndex => _statusEntries[FileStatus.RenamedInIndex]; + + public List RenamedInWorkDir => _statusEntries[FileStatus.RenamedInWorkdir]; + + public List Conflicted => _statusEntries[FileStatus.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..83d92b6907 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; @@ -18,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; @@ -30,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,237 +67,9 @@ 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)); - } - } - } - } + var repoStatus = _statusCache.Status; string branchName; var branchStatus = string.Empty; @@ -356,37 +125,32 @@ public string GetFileStatus(string relativePath) return string.Empty; } - FileStatus status; - try - { - _repoLock.EnterWriteLock(); - status = _repo.RetrieveStatus(relativePath); - } - finally + GitStatusEntry? status; + if (!_statusCache.Status.FileEntries.TryGetValue(relativePath, out status)) { - _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 new file mode 100644 index 0000000000..d9db6314f7 --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/StatusCache.cs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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. +// 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.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.ExitReadLock(); + _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. + if (oldStatus == null) + { + return; + } + + HashSet changed = []; + foreach (var newEntry in newStatus.FileEntries) + { + GitStatusEntry? oldValue; + if (oldStatus.FileEntries.TryGetValue(newEntry.Key, out oldValue)) + { + if (newEntry.Value.Status != oldValue.Status) + { + changed.Add(newEntry.Key); + } + + oldStatus.FileEntries.Remove(newEntry.Key); + } + else + { + changed.Add(newEntry.Key); + } + } + + foreach (var oldEntry in oldStatus.FileEntries) + { + 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() + { + var repoStatus = new GitRepositoryStatus(); + 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) + { + 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..a06d04bf4d --- /dev/null +++ b/extensions/GitExtension/FileExplorerGitIntegration/Models/ThrottledTask.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace FileExplorerGitIntegration.Models; + +internal sealed class ThrottledTask +{ + private readonly TimeSpan _interval; + private readonly object _lock = new(); + + private readonly Action _action; + + 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; + } + + // 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) + { + if (_currentTask != null && !_currentTask.IsCompleted) + { + _shouldQueue = true; + return; + } + + _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); + } + } +} 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