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;