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
9 changes: 9 additions & 0 deletions src/Kontext.Contracts/IHiddenRepositoryStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Kontext.Contracts;

/// <summary>Remembers which repositories the user chose to hide from the issue menu, by <c>RepositoryRef.DisplayName</c>.</summary>
public interface IHiddenRepositoryStore
{
IReadOnlySet<string> Load();

void Save(IReadOnlySet<string> hidden);
}
72 changes: 69 additions & 3 deletions src/Kontext.Presentation/IssueSidebarViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public sealed partial class IssueSidebarViewModel : ObservableObject, IDisposabl
private readonly IIssueWorkTracker _tracker;
private readonly IExistingWorkspaceFinder _existingWorkspaces;
private readonly IWorkspaceIssues _workspaceIssues;
private readonly IHiddenRepositoryStore _hiddenRepositories;
private readonly TimeProvider _clock;
private readonly HashSet<string> _hidden;
private readonly LatestOnly _loads = new();
private readonly Dictionary<string, RepositoryGroupViewModel> _groupsByName = new(StringComparer.OrdinalIgnoreCase);

Expand All @@ -26,19 +28,24 @@ public IssueSidebarViewModel(
IIssueWorkTracker tracker,
IExistingWorkspaceFinder existingWorkspaces,
IWorkspaceIssues workspaceIssues,
IHiddenRepositoryStore hiddenRepositories,
TimeProvider clock)
{
_gitHub = gitHub;
_tracker = tracker;
_existingWorkspaces = existingWorkspaces;
_workspaceIssues = workspaceIssues;
_hiddenRepositories = hiddenRepositories;
_clock = clock;
_hidden = new HashSet<string>(hiddenRepositories.Load(), StringComparer.OrdinalIgnoreCase);

_tracker.Changed += OnTrackerChanged;
}

public ObservableCollection<RepositoryGroupViewModel> Groups { get; } = [];

public ObservableCollection<RepositoryFilterItemViewModel> RepositoryFilters { get; } = [];

[ObservableProperty]
public partial IssueItemViewModel? SelectedItem { get; set; }

Expand All @@ -60,6 +67,12 @@ public IssueSidebarViewModel(

public bool IsEmpty => _loadedOnce && !IsLoading && !HasError && _all.Count == 0;

public bool IsEverythingHidden => _all.Count > 0 && _all.All(IsHidden);

public bool HasNoRepositoryFilters => RepositoryFilters.Count == 0;

public bool IsFilterActive => RepositoryFilters.Any(filter => !filter.IsVisible);

public void Dispose()
{
_tracker.Changed -= OnTrackerChanged;
Expand All @@ -74,6 +87,9 @@ public void Clear()
_itemsByKey = [];
_groupsByName.Clear();
Groups.Clear();
RepositoryFilters.Clear();
OnPropertyChanged(nameof(HasNoRepositoryFilters));
OnPropertyChanged(nameof(IsFilterActive));

_loadedOnce = false;

Expand Down Expand Up @@ -117,6 +133,7 @@ private async Task RefreshAsync()
ErrorMessage = ex.Message;
_all = [];
_itemsByKey = [];
RebuildRepositoryFilters();
ApplyFilter();
}

Expand All @@ -138,6 +155,52 @@ private void Show(IReadOnlyList<GitHubIssue> issues)

SelectedItem = previouslySelected is { } key && _itemsByKey.TryGetValue(key, out var reloaded) ? reloaded : null;

RebuildRepositoryFilters();
ApplyFilter();
}

private bool IsHidden(IssueItemViewModel item) => _hidden.Contains(item.Issue.Repository.DisplayName);

private void RebuildRepositoryFilters()
{
RepositoryFilters.Clear();

var names = _all.Select(item => item.Issue.Repository.DisplayName).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase);
foreach (var name in names)
{
RepositoryFilters.Add(new RepositoryFilterItemViewModel(name, isVisible: !_hidden.Contains(name), OnRepositoryVisibilityChanged));
}

OnPropertyChanged(nameof(HasNoRepositoryFilters));
}

[RelayCommand]
private void ClearFilters()
{
if (_hidden.Count == 0)
{
return;
}

_hidden.Clear();
_hiddenRepositories.Save(_hidden);

RebuildRepositoryFilters();
ApplyFilter();
}

private void OnRepositoryVisibilityChanged(RepositoryFilterItemViewModel filter)
{
if (filter.IsVisible)
{
_hidden.Remove(filter.Name);
}
else
{
_hidden.Add(filter.Name);
}

_hiddenRepositories.Save(_hidden);
ApplyFilter();
}

Expand Down Expand Up @@ -169,7 +232,7 @@ private void MarkExistingWorkspaces(IReadOnlyList<ExistingWorkspace> workspaces)

private void ApplyFilter()
{
var visible = _all.Where(item => IssueSearch.Matches(item.Issue, SearchText)).ToList();
var visible = _all.Where(item => !IsHidden(item) && IssueSearch.Matches(item.Issue, SearchText)).ToList();

var groups = IssueGrouper.Group(visible, item => item.Issue.Repository.DisplayName, item => item.Issue.UpdatedAt);

Expand All @@ -195,6 +258,8 @@ private void ApplyFilter()

CountText = BuildCountText(visible.Count);
OnPropertyChanged(nameof(IsEmpty));
OnPropertyChanged(nameof(IsEverythingHidden));
OnPropertyChanged(nameof(IsFilterActive));
}

private string BuildCountText(int visibleCount)
Expand All @@ -204,8 +269,9 @@ private string BuildCountText(int visibleCount)
return string.Empty;
}

var closed = _all.Count(item => item.Issue.IsClosed);
var open = _all.Count - closed;
var shown = _all.Where(item => !IsHidden(item)).ToList();
var closed = shown.Count(item => item.Issue.IsClosed);
var open = shown.Count - closed;

var issues = open == 1 ? "1 open issue" : $"{open} open issues";
var text = string.IsNullOrWhiteSpace(SearchText) ? issues : $"{visibleCount} of {issues}";
Expand Down
23 changes: 23 additions & 0 deletions src/Kontext.Presentation/RepositoryFilterItemViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Kontext.Presentation;

public sealed class RepositoryFilterItemViewModel(string name, bool isVisible, Action<RepositoryFilterItemViewModel> changed)
{
private bool _isVisible = isVisible;

public string Name { get; } = name;

public bool IsVisible
{
get => _isVisible;
set
{
if (_isVisible == value)
{
return;
}

_isVisible = value;
changed(this);
}
}
}
44 changes: 44 additions & 0 deletions src/Kontext.Sessions/JsonHiddenRepositoryStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Text.Json;
using Kontext.Contracts;

namespace Kontext.Sessions;

internal sealed class JsonHiddenRepositoryStore(SessionsOptions options) : IHiddenRepositoryStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

private readonly Lock _gate = new();
private readonly string _filePath = Path.Combine(options.StateDirectory, "hidden-repositories.json");

public IReadOnlySet<string> Load()
{
lock (_gate)
{
if (!File.Exists(_filePath))
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}

try
{
var names = JsonSerializer.Deserialize<string[]>(File.ReadAllText(_filePath)) ?? [];
return new HashSet<string>(names, StringComparer.OrdinalIgnoreCase);
}
catch (JsonException)
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
}
}

public void Save(IReadOnlySet<string> hidden)
{
ArgumentNullException.ThrowIfNull(hidden);

lock (_gate)
{
Directory.CreateDirectory(options.StateDirectory);
File.WriteAllText(_filePath, JsonSerializer.Serialize(hidden.Order(StringComparer.OrdinalIgnoreCase), JsonOptions));
}
}
}
3 changes: 2 additions & 1 deletion src/Kontext.Sessions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public static IServiceCollection AddKontextSessions(this IServiceCollection serv

services.AddSingleton(options);
services.AddSingleton<ISessionStore, JsonSessionStore>();
services.AddSingleton<IIssueSessionRegistry, IssueSessionRegistry>();
services.AddSingleton<IHiddenRepositoryStore, JsonHiddenRepositoryStore>();
services.AddSingleton<IIssueSessionRegistry,IssueSessionRegistry>();
services.AddSingleton<IIssueWorkTracker, IssueWorkTracker>();
services.AddSingleton<IIssueSessionCoordinator, IssueSessionCoordinator>();
services.AddSingleton<IIssueSessionClearer, IssueSessionClearer>();
Expand Down
4 changes: 3 additions & 1 deletion src/Kontext/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ public static IServiceCollection AddKontext(this IServiceCollection services, IC
WorktreesRoot: Path.Combine(dataRoot, "worktrees"),
ClonesRoot: Path.Combine(dataRoot, "repos")))
.AddKontextTerminalHost()
.AddKontextClaude(new ClaudeOptions(ClaudeConfigDirectory()))
.AddKontextClaude(new ClaudeOptions(
ClaudeConfigDirectory(),
PermissionMode: configuration["Claude:PermissionMode"] is { Length: > 0 } mode ? mode : "plan"))
.AddKontextExternalTools()
.AddKontextSessions(new SessionsOptions(dataRoot))
.AddKontextPresentation();
Expand Down
41 changes: 39 additions & 2 deletions src/Kontext/Terminal/TerminalControlHost.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using EasyWindowsTerminalControl;
using Kontext.Domain;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

Expand Down Expand Up @@ -41,7 +42,43 @@ public void Show(EasyTerminalControl? control)
if (control is not null)
{
control.Visibility = Visibility.Visible;
control.Focus(FocusState.Programmatic);
FocusWhenReady(control);
}
}
}

// A control that has only just become visible cannot take focus until it has been laid out, and the click that selected
// the issue hands focus to the menu item after this runs. So focus once layout and that click have finished, and once more
// when the control finishes loading if it still was not ready.
private static void FocusWhenReady(EasyTerminalControl control)
{
if (!control.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => Focus(control)))
{
Focus(control);
}
}

private static void Focus(EasyTerminalControl control)
{
if (control.Visibility != Visibility.Visible)
{
return;
}

control.UpdateLayout();
if (control.Focus(FocusState.Programmatic) || control.IsLoaded)
{
return;
}

void OnLoaded(object sender, RoutedEventArgs args)
{
control.Loaded -= OnLoaded;
if (control.Visibility == Visibility.Visible)
{
control.Focus(FocusState.Programmatic);
}
}

control.Loaded += OnLoaded;
}
}
Loading