diff --git a/src/Kontext.Presentation/IssueSidebarViewModel.cs b/src/Kontext.Presentation/IssueSidebarViewModel.cs index 6451949..c4653da 100644 --- a/src/Kontext.Presentation/IssueSidebarViewModel.cs +++ b/src/Kontext.Presentation/IssueSidebarViewModel.cs @@ -17,6 +17,7 @@ public sealed partial class IssueSidebarViewModel : ObservableObject, IDisposabl private readonly ICollapsedRepositoryStore _collapsedRepositories; private readonly ISessionStore _sessions; private readonly TimeProvider _clock; + private readonly RefreshOptions _refreshOptions; private readonly HashSet _hidden; private readonly HashSet _collapsed; private readonly LatestOnly _loads = new(); @@ -25,6 +26,8 @@ public sealed partial class IssueSidebarViewModel : ObservableObject, IDisposabl private List _all = []; private Dictionary _itemsByKey = []; + private CancellationTokenSource? _autoRefresh; + private int _removals; private bool _loadedOnce; public IssueSidebarViewModel( @@ -35,7 +38,8 @@ public IssueSidebarViewModel( IHiddenRepositoryStore hiddenRepositories, ICollapsedRepositoryStore collapsedRepositories, ISessionStore sessions, - TimeProvider clock) + TimeProvider clock, + RefreshOptions? refreshOptions = null) { _gitHub = gitHub; _tracker = tracker; @@ -45,6 +49,7 @@ public IssueSidebarViewModel( _collapsedRepositories = collapsedRepositories; _sessions = sessions; _clock = clock; + _refreshOptions = refreshOptions ?? new RefreshOptions(TimeSpan.Zero); _hidden = new HashSet(hiddenRepositories.Load(), StringComparer.OrdinalIgnoreCase); _collapsed = new HashSet(collapsedRepositories.Load(), StringComparer.OrdinalIgnoreCase); @@ -95,11 +100,13 @@ public IssueSidebarViewModel( public void Dispose() { _tracker.Changed -= OnTrackerChanged; + StopAutoRefresh(); _loads.Dispose(); } public void Clear() { + StopAutoRefresh(); _loads.CancelCurrent(); _all = []; @@ -125,11 +132,59 @@ public void Clear() [RelayCommand(AllowConcurrentExecutions = true)] private async Task RefreshAsync() { + StartAutoRefresh(); + await LoadAsync(silent: false); + } + + private void StartAutoRefresh() + { + if (!_refreshOptions.IsEnabled || _autoRefresh is not null) + { + return; + } + + _autoRefresh = new CancellationTokenSource(); + _ = RunAutoRefreshAsync(_autoRefresh.Token); + } + + private void StopAutoRefresh() + { + _autoRefresh?.Cancel(); + _autoRefresh?.Dispose(); + _autoRefresh = null; + } + + private async Task RunAutoRefreshAsync(CancellationToken token) + { + try + { + using var timer = new PeriodicTimer(_refreshOptions.Interval, _clock); + while (await timer.WaitForNextTickAsync(token)) + { + await LoadAsync(silent: true); + } + } + catch (OperationCanceledException) + { + } + } + + private async Task LoadAsync(bool silent) + { + if (silent && IsLoading) + { + return; + } + var load = _loads.Begin(); var token = load.Token; + var removals = _removals; - IsLoading = true; - ErrorMessage = null; + if (!silent) + { + IsLoading = true; + ErrorMessage = null; + } try { @@ -139,9 +194,15 @@ private async Task RefreshAsync() var withWorkspace = await _workspaceIssues.FindMissingAsync(open, workspaces, token); token.ThrowIfCancellationRequested(); + if (silent && removals != _removals) + { + return; + } + Show([.. open, .. withWorkspace]); MarkExistingWorkspaces(workspaces); _loadedOnce = true; + ErrorMessage = null; } catch (OperationCanceledException) when (token.IsCancellationRequested) { @@ -151,14 +212,17 @@ private async Task RefreshAsync() catch (Exception ex) #pragma warning restore CA1031 { - ErrorMessage = ex.Message; - _all = []; - _itemsByKey = []; - RebuildRepositoryFilters(); - ApplyFilter(); + if (!silent) + { + ErrorMessage = ex.Message; + _all = []; + _itemsByKey = []; + RebuildRepositoryFilters(); + ApplyFilter(); + } } - if (_loads.IsCurrent(load)) + if (!silent && _loads.IsCurrent(load)) { IsLoading = false; } @@ -429,6 +493,7 @@ private void Remove(IssueItemViewModel item) SelectedItem = null; } + _removals++; _all.Remove(item); _itemsByKey.Remove(item.Key); ApplyFilter(); diff --git a/src/Kontext.Presentation/RefreshOptions.cs b/src/Kontext.Presentation/RefreshOptions.cs new file mode 100644 index 0000000..e588c63 --- /dev/null +++ b/src/Kontext.Presentation/RefreshOptions.cs @@ -0,0 +1,8 @@ +namespace Kontext.Presentation; + +public sealed record RefreshOptions(TimeSpan Interval) +{ + public static RefreshOptions Default { get; } = new(TimeSpan.FromSeconds(60)); + + public bool IsEnabled => Interval > TimeSpan.Zero; +} diff --git a/src/Kontext.Presentation/ServiceCollectionExtensions.cs b/src/Kontext.Presentation/ServiceCollectionExtensions.cs index 2213e46..d58424e 100644 --- a/src/Kontext.Presentation/ServiceCollectionExtensions.cs +++ b/src/Kontext.Presentation/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Kontext.Presentation; @@ -10,6 +11,7 @@ public static IServiceCollection AddKontextPresentation(this IServiceCollection services.AddSingleton(); services.AddSingleton(AutoStartOptions.Default); + services.TryAddSingleton(RefreshOptions.Default); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Kontext.Sessions/IssueSessionClearer.cs b/src/Kontext.Sessions/IssueSessionClearer.cs index bef34bc..e60b790 100644 --- a/src/Kontext.Sessions/IssueSessionClearer.cs +++ b/src/Kontext.Sessions/IssueSessionClearer.cs @@ -1,4 +1,4 @@ -using Kontext.Contracts; +using Kontext.Contracts; using Kontext.Domain; using Kontext.Kernel; @@ -12,7 +12,9 @@ internal sealed class IssueSessionClearer( IWorkspaceRemover workspaces, TimeProvider time) : IIssueSessionClearer { + private const int RemoveAttempts = 3; private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(1); public async Task ClearAsync(GitHubIssue issue, CancellationToken cancellationToken = default) { @@ -24,17 +26,15 @@ public async Task ClearAsync(GitHubIssue issue, CancellationToken cancellationTo var worktreePath = state.WorktreePath ?? stored?.WorktreePath; var branchName = state.BranchName ?? stored?.BranchName; - await EndSessionAsync(key); + var exited = await EndSessionAsync(key); if (worktreePath is not null && branchName is not null) { - try + var failure = await RemoveWorkspaceAsync(issue, worktreePath, branchName, cancellationToken); + if (failure is not null) { - await workspaces.RemoveAsync(issue.Repository, worktreePath, branchName, cancellationToken); - } - catch (KontextException ex) - { - tracker.Report(key, new IssueWorkState(IssueWorkStatus.Failed, branchName, worktreePath, ex.Message, SessionEnded: true)); + var message = exited ? failure.Message : $"The terminal did not close in time. {failure.Message}"; + tracker.Report(key, new IssueWorkState(IssueWorkStatus.Failed, branchName, worktreePath, message, SessionEnded: true)); return; } } @@ -43,23 +43,46 @@ public async Task ClearAsync(GitHubIssue issue, CancellationToken cancellationTo tracker.Report(key, IssueWorkState.NotStarted); } - private async Task EndSessionAsync(IssueKey key) + private async Task RemoveWorkspaceAsync(GitHubIssue issue, string worktreePath, string branchName, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + try + { + await workspaces.RemoveAsync(issue.Repository, worktreePath, branchName, cancellationToken); + return null; + } + catch (KontextException ex) when (attempt >= RemoveAttempts) + { + return ex; + } + catch (KontextException) + { + await Task.Delay(RetryDelay, time, cancellationToken); + } + } + } + + private async Task EndSessionAsync(IssueKey key) { if (registry.Find(key) is not { } session) { - return; + return true; } terminals.Close(session.Id); + var exited = true; try { await session.Exited.WaitAsync(ExitTimeout, time); } catch (TimeoutException) { + exited = false; } registry.Remove(key); + return exited; } } diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs index 31e447c..44e7396 100644 --- a/src/Kontext/ServiceCollectionExtensions.cs +++ b/src/Kontext/ServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static IServiceCollection AddKontext(this IServiceCollection services, IC ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kontext") : overridden; + services.AddSingleton(new RefreshOptions(RefreshInterval(configuration))); services .AddKontextGitHub(new GitHubOptions(configuration["GitHub:ClientId"])) .AddKontextGitAutomation(new GitAutomationOptions( @@ -69,6 +70,11 @@ public static IServiceCollection AddKontext(this IServiceCollection services, IC return services; } + private static TimeSpan RefreshInterval(IConfiguration configuration) => + int.TryParse(configuration["Refresh:IntervalSeconds"], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 + ? TimeSpan.FromSeconds(seconds) + : RefreshOptions.Default.Interval; + private static string ClaudeConfigDirectory() { var configured = Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR"); diff --git a/src/Kontext/appsettings.json b/src/Kontext/appsettings.json index cf834ed..dec6499 100644 --- a/src/Kontext/appsettings.json +++ b/src/Kontext/appsettings.json @@ -1,5 +1,8 @@ { "GitHub": { "ClientId": "" + }, + "Refresh": { + "IntervalSeconds": 60 } } diff --git a/tests/Kontext.Presentation.UnitTests/IssueSidebarAutoRefreshTests.cs b/tests/Kontext.Presentation.UnitTests/IssueSidebarAutoRefreshTests.cs new file mode 100644 index 0000000..db0a3d3 --- /dev/null +++ b/tests/Kontext.Presentation.UnitTests/IssueSidebarAutoRefreshTests.cs @@ -0,0 +1,112 @@ +using Kontext.Domain; +using Kontext.TestSupport; +using Microsoft.Extensions.Time.Testing; + +namespace Kontext.Presentation.UnitTests; + +[TestFixture] +public class IssueSidebarAutoRefreshTests +{ + private static readonly TimeSpan Interval = TimeSpan.FromSeconds(60); + + private FakeTimeProvider _clock = null!; + private InMemoryIssueWorkTracker _tracker = null!; + private FakeGitHubService _gitHub = null!; + private IssueSidebarViewModel? _sidebar; + + [SetUp] + public void Setup() + { + _clock = new FakeTimeProvider(); + _tracker = new InMemoryIssueWorkTracker(); + _gitHub = new FakeGitHubService { Issues = [Issues.Create(4, "Add notifications", "acme/memento")] }; + } + + [TearDown] + public void Teardown() => _sidebar?.Dispose(); + + private IssueSidebarViewModel CreateSidebar(TimeSpan interval) => + _sidebar = new( + _gitHub, + _tracker, + new FakeExistingWorkspaceFinder(), + new WorkspaceIssues(_gitHub), + new InMemoryHiddenRepositoryStore(), + new InMemoryCollapsedRepositoryStore(), + new FakeSessionStore(), + _clock, + new RefreshOptions(interval)); + + private static int IssueCount(IssueSidebarViewModel sidebar) => sidebar.Groups.SelectMany(group => group.Items).Count(); + + [Test] + public async Task ShowsANewIssueWithinOneInterval() + { + var sidebar = CreateSidebar(Interval); + await sidebar.RefreshCommand.ExecuteAsync(null); + _gitHub.Issues = [.. _gitHub.Issues, Issues.Create(5, "Another", "acme/memento")]; + + _clock.Advance(Interval); + + await AsyncAssert.EventuallyAsync(() => IssueCount(sidebar) == 2, "the new issue to show up"); + } + + [Test] + public async Task DoesNotShowALoadingStateOrDisturbSearchAndSelection() + { + var sidebar = CreateSidebar(Interval); + await sidebar.RefreshCommand.ExecuteAsync(null); + sidebar.SelectedItem = sidebar.Groups.SelectMany(group => group.Items).Single(); + sidebar.SearchText = "notif"; + var loading = new List(); + sidebar.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(IssueSidebarViewModel.IsLoading)) { loading.Add(sidebar.IsLoading); } }; + _gitHub.Issues = [.. _gitHub.Issues, Issues.Create(5, "Notify more", "acme/memento")]; + + _clock.Advance(Interval); + + await AsyncAssert.EventuallyAsync(() => IssueCount(sidebar) == 2, "the refresh to finish"); + Assert.That(loading, Is.Empty); + Assert.That(sidebar.SearchText, Is.EqualTo("notif")); + Assert.That(sidebar.SelectedItem?.Issue.Number, Is.EqualTo(4)); + } + + [Test] + public async Task KeepsTheCurrentListWhenABackgroundRefreshFails() + { + var sidebar = CreateSidebar(Interval); + await sidebar.RefreshCommand.ExecuteAsync(null); + _gitHub.FailWith = new InvalidOperationException("offline"); + + _clock.Advance(Interval); + await Task.Delay(100); + + Assert.That(IssueCount(sidebar), Is.EqualTo(1)); + Assert.That(sidebar.ErrorMessage, Is.Null); + } + + [Test] + public async Task DoesNotRefreshWhenTheIntervalIsZero() + { + var sidebar = CreateSidebar(TimeSpan.Zero); + await sidebar.RefreshCommand.ExecuteAsync(null); + _gitHub.Issues = [.. _gitHub.Issues, Issues.Create(5, "Another", "acme/memento")]; + + _clock.Advance(TimeSpan.FromMinutes(10)); + await Task.Delay(100); + + Assert.That(IssueCount(sidebar), Is.EqualTo(1)); + } + + [Test] + public async Task StopsRefreshingAfterSignOut() + { + var sidebar = CreateSidebar(Interval); + await sidebar.RefreshCommand.ExecuteAsync(null); + + sidebar.Clear(); + _clock.Advance(Interval * 3); + await Task.Delay(100); + + Assert.That(IssueCount(sidebar), Is.Zero); + } +} diff --git a/tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs b/tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs index 01b6199..cfc2056 100644 --- a/tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs +++ b/tests/Kontext.Sessions.UnitTests/IssueSessionClearerTests.cs @@ -1,4 +1,4 @@ -using Kontext.Domain; +using Kontext.Domain; using Kontext.Kernel; using Kontext.TestSupport; @@ -94,4 +94,28 @@ public async Task ReportsFailedAndKeepsTheStoredSessionWhenGitCannotRemoveTheWor Assert.That(state.WorktreePath, Is.EqualTo(Worktree.Path)); Assert.That(_store.Find(Key), Is.Not.Null); } + + [Test] + public async Task RetriesRemovingTheWorkspaceWhenTheFirstAttemptFails() + { + await ASessionIsOpenAsync(); + var remover = new FlakyWorkspaceRemover(failures: 1); + var clearer = new IssueSessionClearer(_tracker, _store, _terminals, _registry, remover, TimeProvider.System); + + await clearer.ClearAsync(_issue); + + Assert.That(remover.Attempts, Is.EqualTo(2)); + Assert.That(_tracker.StateOf(Key), Is.EqualTo(IssueWorkState.NotStarted)); + } + + private sealed class FlakyWorkspaceRemover(int failures) : Kontext.Contracts.IWorkspaceRemover + { + public int Attempts { get; private set; } + + public Task RemoveAsync(RepositoryRef repository, string worktreePath, string branchName, CancellationToken cancellationToken = default) + { + Attempts++; + return Attempts <= failures ? Task.FromException(new TestKontextException("the folder is in use")) : Task.CompletedTask; + } + } }