Skip to content
Merged
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
83 changes: 74 additions & 9 deletions src/Kontext.Presentation/IssueSidebarViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> _hidden;
private readonly HashSet<string> _collapsed;
private readonly LatestOnly _loads = new();
Expand All @@ -25,6 +26,8 @@ public sealed partial class IssueSidebarViewModel : ObservableObject, IDisposabl

private List<IssueItemViewModel> _all = [];
private Dictionary<IssueKey, IssueItemViewModel> _itemsByKey = [];
private CancellationTokenSource? _autoRefresh;
private int _removals;
private bool _loadedOnce;

public IssueSidebarViewModel(
Expand All @@ -35,7 +38,8 @@ public IssueSidebarViewModel(
IHiddenRepositoryStore hiddenRepositories,
ICollapsedRepositoryStore collapsedRepositories,
ISessionStore sessions,
TimeProvider clock)
TimeProvider clock,
RefreshOptions? refreshOptions = null)
{
_gitHub = gitHub;
_tracker = tracker;
Expand All @@ -45,6 +49,7 @@ public IssueSidebarViewModel(
_collapsedRepositories = collapsedRepositories;
_sessions = sessions;
_clock = clock;
_refreshOptions = refreshOptions ?? new RefreshOptions(TimeSpan.Zero);
_hidden = new HashSet<string>(hiddenRepositories.Load(), StringComparer.OrdinalIgnoreCase);
_collapsed = new HashSet<string>(collapsedRepositories.Load(), StringComparer.OrdinalIgnoreCase);

Expand Down Expand Up @@ -95,11 +100,13 @@ public IssueSidebarViewModel(
public void Dispose()
{
_tracker.Changed -= OnTrackerChanged;
StopAutoRefresh();
_loads.Dispose();
}

public void Clear()
{
StopAutoRefresh();
_loads.CancelCurrent();

_all = [];
Expand 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
{
Expand All @@ -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)
{
Expand All @@ -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;
}
Expand Down Expand Up @@ -429,6 +493,7 @@ private void Remove(IssueItemViewModel item)
SelectedItem = null;
}

_removals++;
_all.Remove(item);
_itemsByKey.Remove(item.Key);
ApplyFilter();
Expand Down
8 changes: 8 additions & 0 deletions src/Kontext.Presentation/RefreshOptions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions src/Kontext.Presentation/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Kontext.Presentation;

Expand All @@ -10,6 +11,7 @@ public static IServiceCollection AddKontextPresentation(this IServiceCollection

services.AddSingleton<AppLifetime>();
services.AddSingleton(AutoStartOptions.Default);
services.TryAddSingleton(RefreshOptions.Default);
services.AddSingleton<IWorkspaceIssues, WorkspaceIssues>();
services.AddSingleton<SelectionAutoStart>();

Expand Down
43 changes: 33 additions & 10 deletions src/Kontext.Sessions/IssueSessionClearer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Kontext.Contracts;
using Kontext.Contracts;
using Kontext.Domain;
using Kontext.Kernel;

Expand All @@ -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)
{
Expand All @@ -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;
}
}
Expand All @@ -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<KontextException?> 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<bool> 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;
}
}
6 changes: 6 additions & 0 deletions src/Kontext/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions src/Kontext/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"GitHub": {
"ClientId": ""
},
"Refresh": {
"IntervalSeconds": 60
}
}
Loading