diff --git a/src/Kontext.Contracts/IHiddenRepositoryStore.cs b/src/Kontext.Contracts/IHiddenRepositoryStore.cs
new file mode 100644
index 0000000..da24c13
--- /dev/null
+++ b/src/Kontext.Contracts/IHiddenRepositoryStore.cs
@@ -0,0 +1,9 @@
+namespace Kontext.Contracts;
+
+/// Remembers which repositories the user chose to hide from the issue menu, by RepositoryRef.DisplayName.
+public interface IHiddenRepositoryStore
+{
+ IReadOnlySet Load();
+
+ void Save(IReadOnlySet hidden);
+}
diff --git a/src/Kontext.Presentation/IssueSidebarViewModel.cs b/src/Kontext.Presentation/IssueSidebarViewModel.cs
index 30b70be..ac24b92 100644
--- a/src/Kontext.Presentation/IssueSidebarViewModel.cs
+++ b/src/Kontext.Presentation/IssueSidebarViewModel.cs
@@ -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 _hidden;
private readonly LatestOnly _loads = new();
private readonly Dictionary _groupsByName = new(StringComparer.OrdinalIgnoreCase);
@@ -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(hiddenRepositories.Load(), StringComparer.OrdinalIgnoreCase);
_tracker.Changed += OnTrackerChanged;
}
public ObservableCollection Groups { get; } = [];
+ public ObservableCollection RepositoryFilters { get; } = [];
+
[ObservableProperty]
public partial IssueItemViewModel? SelectedItem { get; set; }
@@ -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;
@@ -74,6 +87,9 @@ public void Clear()
_itemsByKey = [];
_groupsByName.Clear();
Groups.Clear();
+ RepositoryFilters.Clear();
+ OnPropertyChanged(nameof(HasNoRepositoryFilters));
+ OnPropertyChanged(nameof(IsFilterActive));
_loadedOnce = false;
@@ -117,6 +133,7 @@ private async Task RefreshAsync()
ErrorMessage = ex.Message;
_all = [];
_itemsByKey = [];
+ RebuildRepositoryFilters();
ApplyFilter();
}
@@ -138,6 +155,52 @@ private void Show(IReadOnlyList 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();
}
@@ -169,7 +232,7 @@ private void MarkExistingWorkspaces(IReadOnlyList 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);
@@ -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)
@@ -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}";
diff --git a/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs b/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs
new file mode 100644
index 0000000..22ed151
--- /dev/null
+++ b/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs
@@ -0,0 +1,23 @@
+namespace Kontext.Presentation;
+
+public sealed class RepositoryFilterItemViewModel(string name, bool isVisible, Action changed)
+{
+ private bool _isVisible = isVisible;
+
+ public string Name { get; } = name;
+
+ public bool IsVisible
+ {
+ get => _isVisible;
+ set
+ {
+ if (_isVisible == value)
+ {
+ return;
+ }
+
+ _isVisible = value;
+ changed(this);
+ }
+ }
+}
diff --git a/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs b/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs
new file mode 100644
index 0000000..1a47de0
--- /dev/null
+++ b/src/Kontext.Sessions/JsonHiddenRepositoryStore.cs
@@ -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 Load()
+ {
+ lock (_gate)
+ {
+ if (!File.Exists(_filePath))
+ {
+ return new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+
+ try
+ {
+ var names = JsonSerializer.Deserialize(File.ReadAllText(_filePath)) ?? [];
+ return new HashSet(names, StringComparer.OrdinalIgnoreCase);
+ }
+ catch (JsonException)
+ {
+ return new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+ }
+
+ public void Save(IReadOnlySet hidden)
+ {
+ ArgumentNullException.ThrowIfNull(hidden);
+
+ lock (_gate)
+ {
+ Directory.CreateDirectory(options.StateDirectory);
+ File.WriteAllText(_filePath, JsonSerializer.Serialize(hidden.Order(StringComparer.OrdinalIgnoreCase), JsonOptions));
+ }
+ }
+}
diff --git a/src/Kontext.Sessions/ServiceCollectionExtensions.cs b/src/Kontext.Sessions/ServiceCollectionExtensions.cs
index 2df1d24..273f54f 100644
--- a/src/Kontext.Sessions/ServiceCollectionExtensions.cs
+++ b/src/Kontext.Sessions/ServiceCollectionExtensions.cs
@@ -12,7 +12,8 @@ public static IServiceCollection AddKontextSessions(this IServiceCollection serv
services.AddSingleton(options);
services.AddSingleton();
- services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs
index bdf40c7..1ad6460 100644
--- a/src/Kontext/ServiceCollectionExtensions.cs
+++ b/src/Kontext/ServiceCollectionExtensions.cs
@@ -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();
diff --git a/src/Kontext/Terminal/TerminalControlHost.cs b/src/Kontext/Terminal/TerminalControlHost.cs
index 3ca6b69..4f8ae95 100644
--- a/src/Kontext/Terminal/TerminalControlHost.cs
+++ b/src/Kontext/Terminal/TerminalControlHost.cs
@@ -1,5 +1,6 @@
using EasyWindowsTerminalControl;
using Kontext.Domain;
+using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -41,7 +42,43 @@ public void Show(EasyTerminalControl? control)
if (control is not null)
{
control.Visibility = Visibility.Visible;
- control.Focus(FocusState.Programmatic);
+ FocusWhenReady(control);
}
}
-}
\ No newline at end of file
+
+ // 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;
+ }
+}
diff --git a/src/Kontext/Views/WorkspaceView.xaml b/src/Kontext/Views/WorkspaceView.xaml
index 934da89..7693224 100644
--- a/src/Kontext/Views/WorkspaceView.xaml
+++ b/src/Kontext/Views/WorkspaceView.xaml
@@ -8,13 +8,15 @@
-
-
-
+ SelectsOnInvoked="False"
+ ToolTipService.ToolTip="{x:Bind Name}">
+
@@ -86,21 +88,71 @@
Spacing="8">
-
+ Orientation="Horizontal"
+ Spacing="4">
+
+
+
+
+
+
diff --git a/tests/Kontext.CompositionTests/CompositionTests.cs b/tests/Kontext.CompositionTests/CompositionTests.cs
index e790d22..b9bfd15 100644
--- a/tests/Kontext.CompositionTests/CompositionTests.cs
+++ b/tests/Kontext.CompositionTests/CompositionTests.cs
@@ -53,6 +53,7 @@ public void BuildTheContainer()
typeof(IExistingWorkspaceFinder),
typeof(IRepositoryCloneStore),
typeof(ISessionStore),
+ typeof(IHiddenRepositoryStore),
typeof(IIssueWorkTracker),
typeof(IIssueSessionRegistry),
typeof(IIssueSessionCoordinator),
diff --git a/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs
index 1d7270e..8341344 100644
--- a/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs
+++ b/tests/Kontext.Presentation.UnitTests/IssueSidebarViewModelTests.cs
@@ -10,11 +10,16 @@ public class IssueSidebarViewModelTests
private InMemoryIssueWorkTracker _tracker = null!;
private FakeGitHubService _gitHub = null!;
private FakeExistingWorkspaceFinder _finder = null!;
+ private InMemoryHiddenRepositoryStore _hiddenStore = null!;
private IssueSidebarViewModel _sidebar = null!;
+ private IssueSidebarViewModel CreateSidebarWith(InMemoryHiddenRepositoryStore hiddenStore) =>
+ new(_gitHub, _tracker, _finder, new WorkspaceIssues(_gitHub), hiddenStore, new FixedTimeProvider(DateTimeOffset.Parse("2025-03-01T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)));
+
[SetUp]
public void CreateSidebar()
{
+ _hiddenStore = new InMemoryHiddenRepositoryStore();
_tracker = new InMemoryIssueWorkTracker();
_gitHub = new FakeGitHubService();
_finder = new FakeExistingWorkspaceFinder();
@@ -26,7 +31,7 @@ public void CreateSidebar()
Issues.Create(159, "Creating Galleries", "acme/gallery", labels: ["enhancement"]),
];
- _sidebar = new IssueSidebarViewModel(_gitHub, _tracker, _finder, new WorkspaceIssues(_gitHub), new FixedTimeProvider(DateTimeOffset.Parse("2025-03-01T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)));
+ _sidebar = CreateSidebarWith(_hiddenStore);
}
[TearDown]
@@ -67,6 +72,124 @@ public async Task GroupsOpenIssuesByRepository()
Assert.That(_sidebar.IsLoading, Is.False);
}
+ [Test]
+ public async Task HidingARepositoryRemovesItsGroupButKeepsItInTheFilterList()
+ {
+ await Refresh();
+
+ _sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento").IsVisible = false;
+
+ Assert.That(_sidebar.Groups.Select(group => group.Name), Is.EqualTo(Expect.Seq("acme/gallery")));
+ Assert.That(_sidebar.RepositoryFilters.Select(filter => filter.Name), Is.EqualTo(Expect.Seq("acme/gallery", "acme/memento")));
+ Assert.That(_sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento").IsVisible, Is.False);
+ }
+
+ [Test]
+ public async Task ShowingAHiddenRepositoryAgainBringsItsGroupBack()
+ {
+ await Refresh();
+ var memento = _sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento");
+ memento.IsVisible = false;
+
+ memento.IsVisible = true;
+
+ Assert.That(_sidebar.Groups.Select(group => group.Name), Is.EqualTo(Expect.Seq("acme/gallery", "acme/memento")));
+ Assert.That(_hiddenStore.Hidden, Is.Empty);
+ }
+
+ [Test]
+ public async Task RemembersTheHiddenRepositoriesInTheStore()
+ {
+ await Refresh();
+
+ _sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/gallery").IsVisible = false;
+
+ Assert.That(_hiddenStore.Hidden, Is.EquivalentTo(Expect.Seq("acme/gallery")));
+ }
+
+ [Test]
+ public async Task StartsWithTheRepositoriesTheStoreSaysAreHidden()
+ {
+ using var sidebar = CreateSidebarWith(new InMemoryHiddenRepositoryStore("acme/memento"));
+
+ await sidebar.RefreshCommand.ExecuteAsync(null);
+
+ Assert.That(sidebar.Groups.Select(group => group.Name), Is.EqualTo(Expect.Seq("acme/gallery")));
+ Assert.That(sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento").IsVisible, Is.False);
+ }
+
+ [Test]
+ public async Task KeepsARepositoryHiddenAcrossARefresh()
+ {
+ await Refresh();
+ _sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento").IsVisible = false;
+
+ await Refresh();
+
+ Assert.That(_sidebar.Groups.Select(group => group.Name), Is.EqualTo(Expect.Seq("acme/gallery")));
+ Assert.That(_sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento").IsVisible, Is.False);
+ }
+
+ [Test]
+ public async Task CountsOnlyTheIssuesOfRepositoriesThatAreShown()
+ {
+ await Refresh();
+
+ _sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento").IsVisible = false;
+
+ Assert.That(_sidebar.CountText, Is.EqualTo("1 open issue"));
+ }
+
+ [Test]
+ public async Task ShowsThatAFilterIsActiveOnlyWhileARepositoryIsHidden()
+ {
+ await Refresh();
+ Assert.That(_sidebar.IsFilterActive, Is.False);
+
+ var memento = _sidebar.RepositoryFilters.Single(filter => filter.Name == "acme/memento");
+ memento.IsVisible = false;
+
+ Assert.That(_sidebar.IsFilterActive, Is.True);
+
+ memento.IsVisible = true;
+
+ Assert.That(_sidebar.IsFilterActive, Is.False);
+ Assert.That(_sidebar.CountText, Is.EqualTo("3 open issues"));
+ }
+
+ [Test]
+ public async Task ClearingTheFiltersShowsEveryRepositoryAgainAndForgetsThemInTheStore()
+ {
+ await Refresh();
+ foreach (var filter in _sidebar.RepositoryFilters)
+ {
+ filter.IsVisible = false;
+ }
+
+ _sidebar.ClearFiltersCommand.Execute(null);
+
+ Assert.That(_sidebar.Groups.Select(group => group.Name), Is.EqualTo(Expect.Seq("acme/gallery", "acme/memento")));
+ Assert.That(_sidebar.RepositoryFilters, Has.All.Property(nameof(RepositoryFilterItemViewModel.IsVisible)).True);
+ Assert.That(_sidebar.IsFilterActive, Is.False);
+ Assert.That(_sidebar.IsEverythingHidden, Is.False);
+ Assert.That(_hiddenStore.Hidden, Is.Empty);
+ }
+
+ [Test]
+ public async Task SaysEverythingIsHiddenInsteadOfNoOpenIssuesWhenEveryRepositoryIsHidden()
+ {
+ await Refresh();
+
+ foreach (var filter in _sidebar.RepositoryFilters)
+ {
+ filter.IsVisible = false;
+ }
+
+ Assert.That(_sidebar.Groups, Is.Empty);
+ Assert.That(_sidebar.IsEverythingHidden, Is.True);
+ Assert.That(_sidebar.IsEmpty, Is.False);
+ }
+
[Test]
public async Task StartsEveryIssueAsNotStarted()
{
diff --git a/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs
index c03ddbe..a358f85 100644
--- a/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs
+++ b/tests/Kontext.Presentation.UnitTests/ShellViewModelFixture.cs
@@ -39,7 +39,7 @@ protected void Build(string? savedToken, string? savedEnterpriseToken = null)
EnterprisePrompt = new FakeEnterprisePrompt();
DeviceLogin = new FakeGitHubDeviceLogin();
_autoStart = new SelectionAutoStart(coordinator, tracker, new InMemoryIssueSessionRegistry(), TimeProvider.System, AutoStartOptions.Default);
- Sidebar = new IssueSidebarViewModel(GitHub, tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(GitHub), TimeProvider.System);
+ Sidebar = new IssueSidebarViewModel(GitHub, tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(GitHub), new InMemoryHiddenRepositoryStore(), TimeProvider.System);
_detail = new IssueDetailViewModel(coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart);
Shell = new ShellViewModel(GitHub, DeviceLogin, Tokens, EnterprisePrompt, new WorkspaceViewModel(Sidebar, _detail, _autoStart));
}
diff --git a/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs
index e0e687a..79bd266 100644
--- a/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs
+++ b/tests/Kontext.Presentation.UnitTests/WorkspaceViewModelTests.cs
@@ -29,7 +29,7 @@ public async Task CreateWorkspace()
Issues = [Issues.Create(4, "Add notifications", "acme/memento"), Issues.Create(3, "Bring project under UI test", "acme/memento")],
};
- _sidebar = new IssueSidebarViewModel(gitHub, _tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(gitHub), _clock);
+ _sidebar = new IssueSidebarViewModel(gitHub, _tracker, new FakeExistingWorkspaceFinder(), new WorkspaceIssues(gitHub), new InMemoryHiddenRepositoryStore(), _clock);
_detail = new IssueDetailViewModel(_coordinator, new FakeSessionClearer(), new FakeConfirmation(), new FakeWorkspaceOpener(), new InMemoryIssueSessionRegistry(), _autoStart);
_workspace = new WorkspaceViewModel(_sidebar, _detail, _autoStart);
diff --git a/tests/Kontext.Sessions.IntegrationTests/JsonHiddenRepositoryStoreTests.cs b/tests/Kontext.Sessions.IntegrationTests/JsonHiddenRepositoryStoreTests.cs
new file mode 100644
index 0000000..a620435
--- /dev/null
+++ b/tests/Kontext.Sessions.IntegrationTests/JsonHiddenRepositoryStoreTests.cs
@@ -0,0 +1,76 @@
+using Kontext.TestSupport;
+
+namespace Kontext.Sessions.IntegrationTests;
+
+[TestFixture]
+public class JsonHiddenRepositoryStoreTests
+{
+ private string _root = null!;
+
+ [SetUp]
+ public void CreateScratchDirectory()
+ {
+ _root = Path.Combine(Path.GetTempPath(), "kontext-tests", Guid.NewGuid().ToString("N")[..12]);
+ Directory.CreateDirectory(_root);
+ }
+
+ [TearDown]
+ public void DeleteScratchDirectory() => Directory.Delete(_root, recursive: true);
+
+ private JsonHiddenRepositoryStore CreateStore(string? directory = null) => new(new SessionsOptions(directory ?? _root));
+
+ [Test]
+ public void HidesNothingBeforeAnythingIsSaved()
+ {
+ Assert.That(CreateStore().Load(), Is.Empty);
+ }
+
+ [Test]
+ public void RoundTripsTheHiddenRepositories()
+ {
+ CreateStore().Save(new HashSet { "acme/memento", "acme/gallery" });
+
+ Assert.That(CreateStore().Load(), Is.EquivalentTo(Expect.Seq("acme/gallery", "acme/memento")));
+ }
+
+ [Test]
+ public void IgnoresRepositoryCaseWhenLookingUp()
+ {
+ CreateStore().Save(new HashSet { "Acme/Memento" });
+
+ Assert.That(CreateStore().Load().Contains("acme/memento"), Is.True);
+ }
+
+ [Test]
+ public void ForgetsRepositoriesThatAreNoLongerHidden()
+ {
+ var store = CreateStore();
+ store.Save(new HashSet { "acme/memento", "acme/gallery" });
+
+ store.Save(new HashSet { "acme/gallery" });
+
+ Assert.That(CreateStore().Load(), Is.EquivalentTo(Expect.Seq("acme/gallery")));
+ }
+
+ [Test]
+ public void TreatsACorruptFileAsEmptyAndRecoversOnNextSave()
+ {
+ File.WriteAllText(Path.Combine(_root, "hidden-repositories.json"), "[ not json");
+ var store = CreateStore();
+
+ Assert.That(store.Load(), Is.Empty);
+
+ store.Save(new HashSet { "acme/memento" });
+ Assert.That(store.Load(), Is.EquivalentTo(Expect.Seq("acme/memento")));
+ }
+
+ [Test]
+ public void CreatesTheDataDirectoryWhenItDoesNotExistYet()
+ {
+ var store = CreateStore(Path.Combine(_root, "not", "yet"));
+
+ store.Save(new HashSet { "acme/memento" });
+
+ Assert.That(store.Load(), Is.EquivalentTo(Expect.Seq("acme/memento")));
+ }
+}
diff --git a/tests/Kontext.TestSupport/InMemoryFakes.cs b/tests/Kontext.TestSupport/InMemoryFakes.cs
index 09f5a33..8479c18 100644
--- a/tests/Kontext.TestSupport/InMemoryFakes.cs
+++ b/tests/Kontext.TestSupport/InMemoryFakes.cs
@@ -50,6 +50,21 @@ public void Register(IssueKey issue, ITerminalSession session)
public void Remove(IssueKey issue) => _sessions.TryRemove(issue, out _);
}
+public sealed class InMemoryHiddenRepositoryStore(params string[] hidden) : IHiddenRepositoryStore
+{
+ public HashSet Hidden { get; private set; } = new(hidden, StringComparer.OrdinalIgnoreCase);
+
+ public int Saves { get; private set; }
+
+ public IReadOnlySet Load() => new HashSet(Hidden, StringComparer.OrdinalIgnoreCase);
+
+ public void Save(IReadOnlySet hidden)
+ {
+ Hidden = new HashSet(hidden, StringComparer.OrdinalIgnoreCase);
+ Saves++;
+ }
+}
+
public sealed class FakeClaudeLauncher : IClaudeLauncher
{
public bool ReportsItself { get; set; } = true;