Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 25 additions & 12 deletions src/Models/Watcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -249,7 +254,7 @@ private void OnRepositoryChanged(object o, FileSystemEventArgs e)
/// </summary>
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);
Expand Down Expand Up @@ -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);
}
Expand All @@ -302,40 +307,40 @@ 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) ||
name.StartsWith("refs/heads/", StringComparison.Ordinal) ||
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());
}
}

Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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<FileSystemWatcher> _watchers;
private Timer _timer;

private long _lockCount;
private long _lastChange;
private long _updateWC;
private long _updateBranch;
private long _updateSubmodules;
Expand Down
1 change: 1 addition & 0 deletions src/Resources/Locales/en_US.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,7 @@
<x:String x:Key="Text.Preferences.General.EnableCompactFolders" xml:space="preserve">Enable compact folders in changes tree</x:String>
<x:String x:Key="Text.Preferences.General.Locale" xml:space="preserve">Language</x:String>
<x:String x:Key="Text.Preferences.General.MaxHistoryCommits" xml:space="preserve">History Commits</x:String>
<x:String x:Key="Text.Preferences.General.RefreshThrottling" xml:space="preserve">Refresh throttling</x:String>
<x:String x:Key="Text.Preferences.General.ShowChangesPageByDefault" xml:space="preserve">Show `LOCAL CHANGES` page by default</x:String>
<x:String x:Key="Text.Preferences.General.ShowChangesTabInCommitDetailByDefault" xml:space="preserve">Show `CHANGES` tab in commit detail by default</x:String>
<x:String x:Key="Text.Preferences.General.ShowRelativeTimeInGraph" xml:space="preserve">Show relative time in commit graph</x:String>
Expand Down
9 changes: 8 additions & 1 deletion src/ViewModels/Preferences.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -801,6 +807,7 @@ private bool RemoveInvalidRepositoriesRecursive(List<RepositoryNode> 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;
Expand Down
37 changes: 28 additions & 9 deletions src/Views/Preferences.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
<TabItem.Header>
<TextBlock Classes="tab_header" Text="{DynamicResource Text.Preferences.General}"/>
</TabItem.Header>
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,32,32,32,32,32,Auto" ColumnDefinitions="Auto,*">
<Grid Margin="8" RowDefinitions="32,32,32,32,32,32,32,32,32,32,32,32,32,Auto" ColumnDefinitions="Auto,*">
<TextBlock Grid.Row="0" Grid.Column="0"
Text="{DynamicResource Text.Preferences.General.Locale}"
HorizontalAlignment="Right"
Expand Down Expand Up @@ -141,42 +141,61 @@
Text="{Binding MaxHistoryCommits}"/>
</Grid>

<CheckBox Grid.Row="5" Grid.Column="1"
<TextBlock Grid.Row="5" Grid.Column="0"
Text="{DynamicResource Text.Preferences.General.RefreshThrottling}"
HorizontalAlignment="Right"
Margin="0,0,16,0"/>
<Grid Grid.Row="5" Grid.Column="1" ColumnDefinitions="*,64">
<Slider Grid.Column="0"
Minimum="0" Maximum="5000"
TickPlacement="BottomRight" TickFrequency="100"
IsSnapToTickEnabled="True"
VerticalAlignment="Center"
Foreground="{DynamicResource Brush.Border1}"
Value="{Binding RefreshThrottling, Mode=TwoWay}"/>

<TextBlock Grid.Column="1"
HorizontalAlignment="Right" VerticalAlignment="Center"
Foreground="{DynamicResource Brush.FG1}"
Text="{Binding RefreshThrottling, StringFormat='{}{0} ms'}"/>
</Grid>

<CheckBox Grid.Row="6" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.ShowChangesPageByDefault}"
IsChecked="{Binding ShowLocalChangesByDefault, Mode=TwoWay}"/>

<CheckBox Grid.Row="6" Grid.Column="1"
<CheckBox Grid.Row="7" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.ShowChangesTabInCommitDetailByDefault}"
IsChecked="{Binding ShowChangesInCommitDetailByDefault, Mode=TwoWay}"/>

<CheckBox Grid.Row="7" Grid.Column="1"
<CheckBox Grid.Row="8" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.ShowTagsInGraph}"
IsChecked="{Binding ShowTagsInGraph, Mode=TwoWay}"/>

<CheckBox Grid.Row="8" Grid.Column="1"
<CheckBox Grid.Row="9" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.ShowRelativeTimeInGraph}"
IsChecked="{Binding DisplayTimeAsPeriodInHistories, Mode=TwoWay}"/>

<CheckBox Grid.Row="9" Grid.Column="1"
<CheckBox Grid.Row="10" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.UseCompactBranchNames}"
IsChecked="{Binding UseCompactBranchNamesInGraph, Mode=TwoWay}"/>

<CheckBox Grid.Row="10" Grid.Column="1"
<CheckBox Grid.Row="11" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.EnableCompactFolders}"
IsChecked="{Binding EnableCompactFoldersInChangesTree, Mode=TwoWay}"/>

<CheckBox Grid.Row="11" Grid.Column="1"
<CheckBox Grid.Row="12" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.UseGitHubStyleAvatar}"
IsChecked="{Binding UseGitHubStyleAvatar, Mode=TwoWay}"/>

<CheckBox Grid.Row="12" Grid.Column="1"
<CheckBox Grid.Row="13" Grid.Column="1"
Height="32"
Content="{DynamicResource Text.Preferences.General.Check4UpdatesOnStartup}"
IsVisible="{x:Static s:App.IsCheckForUpdateCommandVisible}"
Expand Down
112 changes: 112 additions & 0 deletions tests/WatcherChecks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Reflection;
using SourceGit.Models;

var root = Path.Combine(Path.GetTempPath(), $"sourcegit-watcher-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path.Combine(root, ".git"));
try
{
var repo = new TestRepository();
using var watcher = new Watcher(repo, root, Path.Combine(root, ".git"));
var flags = BindingFlags.Instance | BindingFlags.NonPublic;
var timer = (Timer)typeof(Watcher).GetField("_timer", flags).GetValue(watcher);
timer.Change(Timeout.Infinite, Timeout.Infinite);

void Invoke(string method, params object[] args) => 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<string> 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;
}
}
11 changes: 11 additions & 0 deletions tests/WatcherChecks/WatcherChecks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../src/Models/Watcher.cs" Link="Watcher.cs" />
<Compile Include="../../src/Models/IRepository.cs" Link="IRepository.cs" />
</ItemGroup>
</Project>