From e739c1088952d0a8260594c3f39edb92671dde8a Mon Sep 17 00:00:00 2001 From: Jelle Buning Date: Thu, 5 Nov 2026 12:08:10 +0100 Subject: [PATCH 1/3] Add UI tests and improve sidebar stability --- .github/actions/setup/action.yaml | 22 ++ .github/workflows/build-and-test.yaml | 83 ++++-- Directory.Packages.props | 2 + Kontext.slnx | 1 + src/Kontext.Claude/ClaudeLauncher.cs | 8 +- src/Kontext.Claude/ClaudeLiveSessions.cs | 82 ++++++ .../ClaudeSessionInUseException.cs | 22 ++ src/Kontext.Claude/IClaudeLiveSessions.cs | 10 + .../ServiceCollectionExtensions.cs | 1 + src/Kontext.Presentation/CollectionSync.cs | 54 ++++ src/Kontext.Presentation/GitChangeRow.cs | 4 +- .../IssueDetailViewModel.cs | 10 +- .../IssueSidebarViewModel.cs | 131 ++++++++-- .../RepositoryAccountFilterViewModel.cs | 54 ++++ .../RepositoryFilterItemViewModel.cs | 19 +- .../RepositoryGroupViewModel.cs | 20 +- src/Kontext.Presentation/ShellViewModel.cs | 10 + .../WorkspaceViewModel.cs | 2 + src/Kontext/ServiceCollectionExtensions.cs | 6 + src/Kontext/UiTesting/UiTestGitHubData.cs | 36 +++ src/Kontext/UiTesting/UiTestGitHubService.cs | 27 ++ .../UiTestServiceCollectionExtensions.cs | 21 ++ .../UiTesting/UiTestSessionCoordinator.cs | 10 + src/Kontext/UiTesting/UiTestTokenStore.cs | 19 ++ .../Views/GitChangesTemplateSelectors.cs | 24 -- src/Kontext/Views/GitChangesView.xaml | 151 ++++------- src/Kontext/Views/GitChangesView.xaml.cs | 69 ++++- src/Kontext/Views/IssueDetailView.xaml | 2 + src/Kontext/Views/VisibilityFormat.cs | 2 +- src/Kontext/Views/WorkspaceView.xaml | 58 +++- .../ClaudeLiveSessionsTests.cs | 112 ++++++++ tests/Kontext.Claude.UnitTests/ClaudeFakes.cs | 7 + .../ClaudeLauncherTests.cs | 29 +- .../GitChangeRowTests.cs | 4 +- .../IssueSidebarViewModelTests.cs | 247 ++++++++++++++++++ .../ShellViewModelTests.cs | 8 + .../WorkspaceViewModelTests.cs | 26 +- .../MouseReportEncoderTests.cs | 7 +- .../Kontext.UiTests/Application/FakeGitHub.cs | 22 ++ .../Application/IssueDetailPage.cs | 11 + .../Kontext.UiTests/Application/KontextApp.cs | 43 +++ .../Application/RepositoryFilterPage.cs | 34 +++ .../Application/SidebarPage.cs | 35 +++ .../Application/TestDataDirectory.cs | 36 +++ tests/Kontext.UiTests/Kontext.UiTests.csproj | 50 ++++ .../Kontext.UiTests/Tests/IssueDetailTests.cs | 16 ++ tests/Kontext.UiTests/Tests/KontextFixture.cs | 27 ++ .../Tests/RepositoryFilterTests.cs | 38 +++ tests/Kontext.UiTests/Tests/SidebarTests.cs | 53 ++++ tests/Kontext.UiTests/Tests/StartupTests.cs | 15 ++ 50 files changed, 1593 insertions(+), 187 deletions(-) create mode 100644 .github/actions/setup/action.yaml create mode 100644 src/Kontext.Claude/ClaudeLiveSessions.cs create mode 100644 src/Kontext.Claude/ClaudeSessionInUseException.cs create mode 100644 src/Kontext.Claude/IClaudeLiveSessions.cs create mode 100644 src/Kontext.Presentation/CollectionSync.cs create mode 100644 src/Kontext.Presentation/RepositoryAccountFilterViewModel.cs create mode 100644 src/Kontext/UiTesting/UiTestGitHubData.cs create mode 100644 src/Kontext/UiTesting/UiTestGitHubService.cs create mode 100644 src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs create mode 100644 src/Kontext/UiTesting/UiTestSessionCoordinator.cs create mode 100644 src/Kontext/UiTesting/UiTestTokenStore.cs create mode 100644 tests/Kontext.Claude.IntegrationTests/ClaudeLiveSessionsTests.cs create mode 100644 tests/Kontext.UiTests/Application/FakeGitHub.cs create mode 100644 tests/Kontext.UiTests/Application/IssueDetailPage.cs create mode 100644 tests/Kontext.UiTests/Application/KontextApp.cs create mode 100644 tests/Kontext.UiTests/Application/RepositoryFilterPage.cs create mode 100644 tests/Kontext.UiTests/Application/SidebarPage.cs create mode 100644 tests/Kontext.UiTests/Application/TestDataDirectory.cs create mode 100644 tests/Kontext.UiTests/Kontext.UiTests.csproj create mode 100644 tests/Kontext.UiTests/Tests/IssueDetailTests.cs create mode 100644 tests/Kontext.UiTests/Tests/KontextFixture.cs create mode 100644 tests/Kontext.UiTests/Tests/RepositoryFilterTests.cs create mode 100644 tests/Kontext.UiTests/Tests/SidebarTests.cs create mode 100644 tests/Kontext.UiTests/Tests/StartupTests.cs diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 0000000..1fc0a9f --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -0,0 +1,22 @@ +name: Setup +description: Installs the .NET SDK, loads the NuGet cache and restores the solution. Run after checkout. + +runs: + using: composite + steps: + - name: Setup .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Load nuget cache + uses: actions/cache@v6 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj', '**/Directory.Build.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Install dependencies + shell: bash + run: dotnet restore diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 8d186b0..c83e757 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -5,31 +5,23 @@ on: workflow_dispatch: workflow_call: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build: - # WinUI 3 (Kontext) and ConPTY (Kontext.TerminalHost) only build on Windows. + name: Build runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v7 - - name: Setup .NET SDK - uses: actions/setup-dotnet@v6 - with: - dotnet-version: '10.0.x' + - name: Setup + uses: ./.github/actions/setup - - name: Load nuget cache - uses: actions/cache@v6 - with: - path: | - ~/.nuget/packages - !~/.nuget/packages/unwanted - key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props') }} - restore-keys: | - ${{ runner.os }}-nuget- - - - name: Install dependencies - run: dotnet restore + - name: Check for unused usings + run: dotnet format style Kontext.slnx --diagnostics IDE0005 --severity info --verify-no-changes - name: Set GitHub OAuth client id if: ${{ vars.KONTEXT_GITHUB_CLIENT_ID != '' }} @@ -45,5 +37,58 @@ jobs: - name: Build run: dotnet build --no-restore --configuration Release - - name: Test - run: dotnet test --no-build --configuration Release --logger GitHubActions + - name: Upload build output + uses: actions/upload-artifact@v7 + with: + name: build-output + path: | + src/*/bin/Release + tests/*/bin/Release + if-no-files-found: error + retention-days: 1 + + unit-test: + name: Run unit tests + needs: build + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Download build output + uses: actions/download-artifact@v8 + with: + name: build-output + + - name: Unit test + run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.UnitTests." + + - name: Integration test + run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.IntegrationTests." + + - name: Architecture test + run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.ArchitectureTests.|FullyQualifiedName~.CompositionTests." + + ui-test: + name: Run UI tests + needs: build + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup + uses: ./.github/actions/setup + + - name: Download build output + uses: actions/download-artifact@v8 + with: + name: build-output + + - name: UI test + run: dotnet test --no-build --configuration Release --filter "FullyQualifiedName~.UiTests." diff --git a/Directory.Packages.props b/Directory.Packages.props index a281554..85ab017 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,5 +15,7 @@ + + \ No newline at end of file diff --git a/Kontext.slnx b/Kontext.slnx index cf5e297..54f767a 100644 --- a/Kontext.slnx +++ b/Kontext.slnx @@ -26,5 +26,6 @@ + diff --git a/src/Kontext.Claude/ClaudeLauncher.cs b/src/Kontext.Claude/ClaudeLauncher.cs index 38f6256..73eb382 100644 --- a/src/Kontext.Claude/ClaudeLauncher.cs +++ b/src/Kontext.Claude/ClaudeLauncher.cs @@ -3,7 +3,7 @@ namespace Kontext.Claude; -internal sealed class ClaudeLauncher(ClaudeLaunchBuilder launch, IClaudeTranscripts transcripts, IClaudeHooks hooks) : IClaudeLauncher +internal sealed class ClaudeLauncher(ClaudeLaunchBuilder launch, IClaudeTranscripts transcripts, IClaudeHooks hooks, IClaudeLiveSessions live) : IClaudeLauncher { public ClaudeLaunch Prepare(StoredSession? earlier, WorktreeInfo worktree) { @@ -15,6 +15,12 @@ public ClaudeLaunch Prepare(StoredSession? earlier, WorktreeInfo worktree) ? earlier : null; + // Claude refuses (exit code 1) to resume a session another Claude has open, so say why instead. + if (resumable is not null && live.FindRunningProcess(resumable.SessionId) is not null) + { + throw new ClaudeSessionInUseException(); + } + var id = resumable?.SessionId ?? TerminalSessionId.New(); var settings = hooks.SettingsFor(id); var options = resumable is null diff --git a/src/Kontext.Claude/ClaudeLiveSessions.cs b/src/Kontext.Claude/ClaudeLiveSessions.cs new file mode 100644 index 0000000..c1871f6 --- /dev/null +++ b/src/Kontext.Claude/ClaudeLiveSessions.cs @@ -0,0 +1,82 @@ +using System.Diagnostics; +using System.Text.Json; +using Kontext.Domain; + +namespace Kontext.Claude; + +/// +/// Reads the files Claude Code keeps for its running sessions (<config>/sessions/<pid>.json). A file only +/// counts while its process still runs and started when the file says, so a leftover file whose process id has since been +/// reused by another program is ignored. Anything unreadable is skipped: this check may only prevent a failed resume, +/// never block a good one. +/// +internal sealed class ClaudeLiveSessions(ClaudeOptions options) : IClaudeLiveSessions +{ + public int? FindRunningProcess(TerminalSessionId sessionId) + { + var folder = Path.Combine(options.ConfigDirectory, "sessions"); + if (!Directory.Exists(folder)) + { + return null; + } + + foreach (var file in Directory.EnumerateFiles(folder, "*.json")) + { + if (Read(file) is { } entry && entry.SessionId == sessionId.Value && IsRunning(entry.Pid, entry.ProcessStart)) + { + return entry.Pid; + } + } + + return null; + } + + private static (int Pid, Guid SessionId, long ProcessStart)? Read(string file) + { + try + { + using var document = JsonDocument.Parse(File.ReadAllText(file)); + var root = document.RootElement; + + return root.TryGetProperty("pid", out var pid) && pid.TryGetInt32(out var processId) + && root.TryGetProperty("sessionId", out var session) && Guid.TryParse(session.GetString(), out var id) + && root.TryGetProperty("procStart", out var start) && TryGetInt64(start, out var processStart) + ? (processId, id, processStart) + : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException) + { + return null; + } + } + + // Claude Code writes procStart as a string (it does not fit a JavaScript number); accept a plain number too. + private static bool TryGetInt64(JsonElement element, out long value) + { + if (element.ValueKind == JsonValueKind.String) + { + return long.TryParse(element.GetString(), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out value); + } + + if (element.ValueKind == JsonValueKind.Number) + { + return element.TryGetInt64(out value); + } + + value = 0; + return false; + } + + private static bool IsRunning(int pid, long processStart) + { + try + { + using var process = Process.GetProcessById(pid); + return !process.HasExited && process.StartTime.ToFileTimeUtc() == processStart; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return false; + } + } +} diff --git a/src/Kontext.Claude/ClaudeSessionInUseException.cs b/src/Kontext.Claude/ClaudeSessionInUseException.cs new file mode 100644 index 0000000..3b81fe1 --- /dev/null +++ b/src/Kontext.Claude/ClaudeSessionInUseException.cs @@ -0,0 +1,22 @@ +using Kontext.Kernel; + +namespace Kontext.Claude; + +/// The session to resume is already open in another Claude (another terminal, or another Kontext). +public class ClaudeSessionInUseException : KontextException +{ + public ClaudeSessionInUseException() + : this("This session is already open in another Claude window. Close it there, then resume it here.") + { + } + + public ClaudeSessionInUseException(string message) + : base(message) + { + } + + public ClaudeSessionInUseException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Kontext.Claude/IClaudeLiveSessions.cs b/src/Kontext.Claude/IClaudeLiveSessions.cs new file mode 100644 index 0000000..fee971e --- /dev/null +++ b/src/Kontext.Claude/IClaudeLiveSessions.cs @@ -0,0 +1,10 @@ +using Kontext.Domain; + +namespace Kontext.Claude; + +/// The Claude sessions that are open right now, in any terminal or in another Kontext. +internal interface IClaudeLiveSessions +{ + /// The process id of the running Claude that has open, or null when none has. + int? FindRunningProcess(TerminalSessionId sessionId); +} diff --git a/src/Kontext.Claude/ServiceCollectionExtensions.cs b/src/Kontext.Claude/ServiceCollectionExtensions.cs index 1d429d1..bb34838 100644 --- a/src/Kontext.Claude/ServiceCollectionExtensions.cs +++ b/src/Kontext.Claude/ServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ public static IServiceCollection AddKontextClaude(this IServiceCollection servic services.AddSingleton(options); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Kontext.Presentation/CollectionSync.cs b/src/Kontext.Presentation/CollectionSync.cs new file mode 100644 index 0000000..8822995 --- /dev/null +++ b/src/Kontext.Presentation/CollectionSync.cs @@ -0,0 +1,54 @@ +using System.Collections.ObjectModel; + +namespace Kontext.Presentation; + +/// +/// Updates an to match a desired list using only +/// insert, move and remove notifications (never a reset). New items are inserted before +/// stale ones are removed, so a non-empty collection never passes through empty. +/// Bound controls such as NavigationView then keep their containers (and expand state). +/// +internal static class CollectionSync +{ + public static void SyncInPlace(ObservableCollection target, IReadOnlyList desired) + where T : class + { + for (var index = 0; index < desired.Count; index++) + { + var wanted = desired[index]; + if (index < target.Count && ReferenceEquals(target[index], wanted)) + { + continue; + } + + var existing = IndexOf(target, wanted, from: index + 1); + if (existing >= 0) + { + target.Move(existing, index); + } + else + { + target.Insert(index, wanted); + } + } + + while (target.Count > desired.Count) + { + target.RemoveAt(target.Count - 1); + } + } + + private static int IndexOf(ObservableCollection target, T item, int from) + where T : class + { + for (var index = from; index < target.Count; index++) + { + if (ReferenceEquals(target[index], item)) + { + return index; + } + } + + return -1; + } +} diff --git a/src/Kontext.Presentation/GitChangeRow.cs b/src/Kontext.Presentation/GitChangeRow.cs index 7ecd701..38e18c8 100644 --- a/src/Kontext.Presentation/GitChangeRow.cs +++ b/src/Kontext.Presentation/GitChangeRow.cs @@ -11,10 +11,10 @@ public sealed record GitChangeRow(string Text, FileChange? Change) public string StatusLetter => Change?.Status switch { FileChangeStatus.Modified => "M", - FileChangeStatus.Added => "A", + // A new file is "+", staged or not. + FileChangeStatus.Added or FileChangeStatus.Untracked => "+", FileChangeStatus.Deleted => "D", FileChangeStatus.Renamed => "R", - FileChangeStatus.Untracked => "?", FileChangeStatus.Conflicted => "!", _ => string.Empty, }; diff --git a/src/Kontext.Presentation/IssueDetailViewModel.cs b/src/Kontext.Presentation/IssueDetailViewModel.cs index 032da59..37db733 100644 --- a/src/Kontext.Presentation/IssueDetailViewModel.cs +++ b/src/Kontext.Presentation/IssueDetailViewModel.cs @@ -92,6 +92,14 @@ public IssueDetailViewModel( [NotifyPropertyChangedFor(nameof(VisibleSession))] public partial bool IsConfirmingClear { get; set; } + /// + /// True while another page (the settings) covers the detail pane. The terminal is a native window that XAML cannot + /// hide by collapsing its parent, so no session is shown then. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VisibleSession))] + public partial bool IsCovered { get; set; } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasToolError))] public partial string? ToolError { get; set; } @@ -100,7 +108,7 @@ public IssueDetailViewModel( public ITerminalSession? Session => Item is null ? null : _registry.Find(Item.Key); - public ITerminalSession? VisibleSession => ShowTerminalTab && !IsConfirmingClear ? Session : null; + public ITerminalSession? VisibleSession => ShowTerminalTab && !IsConfirmingClear && !IsCovered ? Session : null; public bool HasSession => Session is not null; diff --git a/src/Kontext.Presentation/IssueSidebarViewModel.cs b/src/Kontext.Presentation/IssueSidebarViewModel.cs index a4352db..6451949 100644 --- a/src/Kontext.Presentation/IssueSidebarViewModel.cs +++ b/src/Kontext.Presentation/IssueSidebarViewModel.cs @@ -21,6 +21,7 @@ public sealed partial class IssueSidebarViewModel : ObservableObject, IDisposabl private readonly HashSet _collapsed; private readonly LatestOnly _loads = new(); private readonly Dictionary _groupsByName = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _loginsByHost = new(StringComparer.OrdinalIgnoreCase); private List _all = []; private Dictionary _itemsByKey = []; @@ -50,10 +51,20 @@ public IssueSidebarViewModel( _tracker.Changed += OnTrackerChanged; } + /// The repository groups that are shown (not filtered out, with a search match). public ObservableCollection Groups { get; } = []; + /// + /// What the menu binds to: a group for every repository, filtered-out ones with + /// false, so filtering and searching never remove or insert menu rows. + /// + public ObservableCollection MenuGroups { get; } = []; + public ObservableCollection RepositoryFilters { get; } = []; + /// The same filters as , as one card per account (github.com first). + public ObservableCollection RepositoryAccounts { get; } = []; + [ObservableProperty] public partial IssueItemViewModel? SelectedItem { get; set; } @@ -95,7 +106,9 @@ public void Clear() _itemsByKey = []; _groupsByName.Clear(); Groups.Clear(); + MenuGroups.Clear(); RepositoryFilters.Clear(); + RepositoryAccounts.Clear(); OnPropertyChanged(nameof(HasNoRepositoryFilters)); OnPropertyChanged(nameof(IsFilterActive)); @@ -169,19 +182,83 @@ private void Show(IReadOnlyList issues) private bool IsHidden(IssueItemViewModel item) => _hidden.Contains(item.Issue.Repository.DisplayName); + /// Remembers who is signed in on , so its account card shows the username (null: signed out). + public void SetAccountLogin(string host, string? login) + { + if (login is null) + { + _loginsByHost.Remove(host); + } + else + { + _loginsByHost[host] = login; + } + + foreach (var account in RepositoryAccounts.Where(account => string.Equals(account.Host, host, StringComparison.OrdinalIgnoreCase))) + { + account.SetLogin(login); + } + } + private void RebuildRepositoryFilters() { RepositoryFilters.Clear(); + RepositoryAccounts.Clear(); + + var repositories = _all + .Select(item => item.Issue.Repository) + .DistinctBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase) + .OrderBy(repository => repository.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToList(); - var names = _all.Select(item => item.Issue.Repository.DisplayName).Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase); - foreach (var name in names) + var filters = repositories.ToDictionary( + repository => repository.DisplayName, + repository => new RepositoryFilterItemViewModel(repository.DisplayName, repository.FullName, isVisible: !_hidden.Contains(repository.DisplayName), OnRepositoryVisibilityChanged), + StringComparer.OrdinalIgnoreCase); + + foreach (var filter in filters.Values) { - RepositoryFilters.Add(new RepositoryFilterItemViewModel(name, isVisible: !_hidden.Contains(name), OnRepositoryVisibilityChanged)); + RepositoryFilters.Add(filter); + } + + var accounts = repositories + .GroupBy(repository => repository.Host, StringComparer.OrdinalIgnoreCase) + .OrderBy(group => GitHubHosts.IsDefault(group.Key) ? 0 : 1) + .ThenBy(group => group.Key, StringComparer.OrdinalIgnoreCase); + + foreach (var account in accounts) + { + RepositoryAccounts.Add(new RepositoryAccountFilterViewModel( + account.Key, + _loginsByHost.GetValueOrDefault(account.Key), + [.. account.Select(repository => filters[repository.DisplayName])], + SetAllVisible)); } OnPropertyChanged(nameof(HasNoRepositoryFilters)); } + private void SetAllVisible(RepositoryAccountFilterViewModel account, bool visible) + { + foreach (var filter in account.Repositories) + { + filter.ShowWithoutReporting(visible); + + if (visible) + { + _hidden.Remove(filter.Name); + } + else + { + _hidden.Add(filter.Name); + } + } + + _hiddenRepositories.Save(_hidden); + account.RepositoriesChanged(); + ApplyFilter(); + } + [RelayCommand] private void ClearFilters() { @@ -209,6 +286,12 @@ private void OnRepositoryVisibilityChanged(RepositoryFilterItemViewModel filter) } _hiddenRepositories.Save(_hidden); + + foreach (var account in RepositoryAccounts.Where(account => account.Repositories.Contains(filter))) + { + account.RepositoriesChanged(); + } + ApplyFilter(); } @@ -258,28 +341,44 @@ private void ApplyFilter() { 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); + var shown = IssueGrouper.Group(visible, item => item.Issue.Repository.DisplayName, item => item.Issue.UpdatedAt) + .ToDictionary(group => group.Repository, StringComparer.OrdinalIgnoreCase); - Groups.Clear(); - foreach (var group in groups) + var repositories = _all + .Select(item => item.Issue.Repository.DisplayName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase); + + var menu = new List(); + var desired = new List(shown.Count); + foreach (var repository in repositories) { - if (!_groupsByName.TryGetValue(group.Repository, out var viewModel)) + var group = shown.GetValueOrDefault(repository); + if (!_groupsByName.TryGetValue(repository, out var viewModel)) { - viewModel = new RepositoryGroupViewModel(group.Repository, group.Items, !_collapsed.Contains(group.Repository), OnGroupExpandedChanged); - _groupsByName[group.Repository] = viewModel; + viewModel = new RepositoryGroupViewModel(repository, group?.Items ?? [], !_collapsed.Contains(repository), OnGroupExpandedChanged); + _groupsByName[repository] = viewModel; } - else + else if (group is not null) { - viewModel.Items.Clear(); - foreach (var item in group.Items) - { - viewModel.Items.Add(item); - } + // A hidden group keeps its last issues: a menu group whose children run out gets collapsed by the menu. + viewModel.SyncItems(group.Items); } - Groups.Add(viewModel); + viewModel.IsVisible = group is not null; + menu.Add(viewModel); + if (group is not null) + { + desired.Add(viewModel); + } } + CollectionSync.SyncInPlace(Groups, desired); + + // The menu keeps a row for every repository and only hides the filtered ones. Removing and re-inserting rows made + // the NavigationView rebuild them, and a rebuilt row could show a different expand state than its group has. + CollectionSync.SyncInPlace(MenuGroups, menu); + CountText = BuildCountText(visible.Count); OnPropertyChanged(nameof(IsEmpty)); OnPropertyChanged(nameof(IsEverythingHidden)); diff --git a/src/Kontext.Presentation/RepositoryAccountFilterViewModel.cs b/src/Kontext.Presentation/RepositoryAccountFilterViewModel.cs new file mode 100644 index 0000000..02dc428 --- /dev/null +++ b/src/Kontext.Presentation/RepositoryAccountFilterViewModel.cs @@ -0,0 +1,54 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Kontext.Presentation; + +/// +/// One account card in the repository filter: the repositories on one GitHub server, under a checkbox that shows or +/// hides all of them at once. +/// +public sealed class RepositoryAccountFilterViewModel( + string host, + string? login, + IReadOnlyList repositories, + Action setAllVisible) : ObservableObject +{ + private string? _login = login; + + public string Host { get; } = host; + + public IReadOnlyList Repositories { get; } = repositories; + + /// The signed-in username and its server (jelle - github.com), or just the server when the username is not known. + public string Title => _login is null ? Host : $"{_login} - {Host}"; + + /// + /// True when every repository is shown, false when none is, null (partly checked) otherwise. Checking shows them all, + /// unchecking hides them all. + /// + public bool? AreAllVisible + { + get => Repositories.All(repository => repository.IsVisible) ? true + : Repositories.Any(repository => repository.IsVisible) ? null + : false; + set + { + if (value is { } visible && value != AreAllVisible) + { + setAllVisible(this, visible); + } + } + } + + internal void SetLogin(string? login) + { + if (_login == login) + { + return; + } + + _login = login; + OnPropertyChanged(nameof(Title)); + } + + internal void RepositoriesChanged() => OnPropertyChanged(nameof(AreAllVisible)); +} diff --git a/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs b/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs index 22ed151..1c2de6d 100644 --- a/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs +++ b/src/Kontext.Presentation/RepositoryFilterItemViewModel.cs @@ -1,23 +1,30 @@ +using CommunityToolkit.Mvvm.ComponentModel; + namespace Kontext.Presentation; -public sealed class RepositoryFilterItemViewModel(string name, bool isVisible, Action changed) +/// One repository checkbox in the filter. is the RepositoryRef.DisplayName it is remembered by. +public sealed class RepositoryFilterItemViewModel(string name, string displayName, bool isVisible, Action changed) + : ObservableObject { private bool _isVisible = isVisible; public string Name { get; } = name; + /// The name shown in the filter, without the server: the account card already says which server it is on. + public string DisplayName { get; } = displayName; + public bool IsVisible { get => _isVisible; set { - if (_isVisible == value) + if (SetProperty(ref _isVisible, value)) { - return; + changed(this); } - - _isVisible = value; - changed(this); } } + + /// Changes the checkbox for a whole-account change, which the sidebar applies and saves once. + internal void ShowWithoutReporting(bool visible) => SetProperty(ref _isVisible, visible, nameof(IsVisible)); } diff --git a/src/Kontext.Presentation/RepositoryGroupViewModel.cs b/src/Kontext.Presentation/RepositoryGroupViewModel.cs index dad14b2..4dbebd4 100644 --- a/src/Kontext.Presentation/RepositoryGroupViewModel.cs +++ b/src/Kontext.Presentation/RepositoryGroupViewModel.cs @@ -1,8 +1,9 @@ using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; namespace Kontext.Presentation; -public sealed class RepositoryGroupViewModel(string name, IEnumerable items, bool isExpanded = true, Action? expandedChanged = null) +public sealed class RepositoryGroupViewModel(string name, IEnumerable items, bool isExpanded = true, Action? expandedChanged = null) : ObservableObject { private bool _isExpanded = isExpanded; @@ -10,19 +11,30 @@ public sealed class RepositoryGroupViewModel(string name, IEnumerable Items { get; } = new(items); - /// Set by the menu when the user expands or collapses the group; a change is reported so it can be remembered. + /// + /// Set by the menu when the user expands or collapses the group; a change is reported so it can be remembered, + /// and raised so the menu shows the remembered value again if it ever differs. + /// public bool IsExpanded { get => _isExpanded; set { - if (_isExpanded == value) + if (!SetProperty(ref _isExpanded, value)) { return; } - _isExpanded = value; expandedChanged?.Invoke(this); } } + + /// False while the group is filtered out or has no search match; the menu keeps its row but hides it. + public bool IsVisible + { + get; + internal set => SetProperty(ref field, value); + } = true; + + internal void SyncItems(IReadOnlyList desired) => CollectionSync.SyncInPlace(Items, desired); } diff --git a/src/Kontext.Presentation/ShellViewModel.cs b/src/Kontext.Presentation/ShellViewModel.cs index 7626cbe..7bdf093 100644 --- a/src/Kontext.Presentation/ShellViewModel.cs +++ b/src/Kontext.Presentation/ShellViewModel.cs @@ -247,6 +247,16 @@ private void SignOutOf(string host, System.Windows.Input.ICommand cancelSignIn) Workspace.IsSettingsOpen = false; } + partial void OnUserChanged(GitHubUser? value) => Workspace.Sidebar.SetAccountLogin(GitHubHosts.Default, value?.Login); + + partial void OnEnterpriseUserChanged(GitHubUser? oldValue, GitHubUser? newValue) + { + if ((newValue ?? oldValue) is { } user) + { + Workspace.Sidebar.SetAccountLogin(user.Host, newValue?.Login); + } + } + private void ShowUser(GitHubUser user) { if (GitHubHosts.IsDefault(user.Host)) diff --git a/src/Kontext.Presentation/WorkspaceViewModel.cs b/src/Kontext.Presentation/WorkspaceViewModel.cs index 6b3afbd..61d4903 100644 --- a/src/Kontext.Presentation/WorkspaceViewModel.cs +++ b/src/Kontext.Presentation/WorkspaceViewModel.cs @@ -26,6 +26,8 @@ public WorkspaceViewModel(IssueSidebarViewModel sidebar, IssueDetailViewModel de public bool IsDetailVisible => !IsSettingsOpen; + partial void OnIsSettingsOpenChanged(bool value) => Detail.IsCovered = value; + private void OnSidebarPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(IssueSidebarViewModel.SelectedItem)) diff --git a/src/Kontext/ServiceCollectionExtensions.cs b/src/Kontext/ServiceCollectionExtensions.cs index 1ad6460..31e447c 100644 --- a/src/Kontext/ServiceCollectionExtensions.cs +++ b/src/Kontext/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Kontext.Terminal; using Kontext.TerminalHost; using Kontext.Tray; +using Kontext.UiTesting; using Kontext.Views; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -60,6 +61,11 @@ public static IServiceCollection AddKontext(this IServiceCollection services, IC services.AddTransient(); services.AddSingleton(); + if (UiTestServiceCollectionExtensions.IsEnabled) + { + services.AddKontextUiTestFakes(); + } + return services; } diff --git a/src/Kontext/UiTesting/UiTestGitHubData.cs b/src/Kontext/UiTesting/UiTestGitHubData.cs new file mode 100644 index 0000000..466c307 --- /dev/null +++ b/src/Kontext/UiTesting/UiTestGitHubData.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using Kontext.Domain; +using Kontext.Kernel; + +namespace Kontext.UiTesting; + +/// Fixed GitHub data the app shows when it runs under UI test (KONTEXT_UI_TEST=1). +internal static class UiTestGitHubData +{ + public static GitHubUser User { get; } = new("ui-tester", "UI Tester", new Uri("https://github.com/ui-tester")); + + public static IReadOnlyList Issues { get; } = + [ + Create("acme", "atlas", 12, "Draw the coastline", "2025-02-24T10:00:00Z"), + Create("acme", "atlas", 11, "Add a legend", "2025-02-14T10:00:00Z"), + Create("acme", "beacon", 7, "Blink on new mail", "2025-02-23T10:00:00Z"), + Create("acme", "beacon", 6, "Dim at night", "2025-02-13T10:00:00Z"), + Create("acme", "compass", 21, "Point north", "2025-02-22T10:00:00Z"), + Create("acme", "compass", 20, "Calibrate on start", "2025-02-12T10:00:00Z"), + Create("acme", "gallery", 159, "Creating Galleries", "2025-02-25T10:00:00Z", "enhancement"), + Create("acme", "memento", 4, "Add notifications", "2025-02-20T10:00:00Z"), + Create("acme", "memento", 3, "Bring project under UI test", "2025-02-10T10:00:00Z"), + ]; + + private static GitHubIssue Create(string owner, string name, int number, string title, string updatedAt, params string[] labels) => + new( + new RepositoryRef(owner, name), + number, + title, + $"Body of {title}.", + "open", + labels, + new Uri($"https://github.com/{owner}/{name}/issues/{number}"), + "octocat", + DateTimeOffset.Parse(updatedAt, CultureInfo.InvariantCulture)); +} diff --git a/src/Kontext/UiTesting/UiTestGitHubService.cs b/src/Kontext/UiTesting/UiTestGitHubService.cs new file mode 100644 index 0000000..cf652dd --- /dev/null +++ b/src/Kontext/UiTesting/UiTestGitHubService.cs @@ -0,0 +1,27 @@ +using Kontext.Contracts; +using Kontext.Domain; +using Kontext.Kernel; + +namespace Kontext.UiTesting; + +internal sealed class UiTestGitHubService : IGitHubService +{ + public Task AuthenticateAsync(string host, string accessToken, CancellationToken cancellationToken = default) => + Task.FromResult(UiTestGitHubData.User with { Host = host }); + + public void SignOut(string host) + { + } + + public Task> GetIssuesAsync(RepositoryRef? repository = null, CancellationToken cancellationToken = default) => + Task.FromResult>( + repository is null ? UiTestGitHubData.Issues : [.. UiTestGitHubData.Issues.Where(issue => Same(issue.Repository, repository))]); + + public Task GetIssueAsync(RepositoryRef repository, int number, CancellationToken cancellationToken = default) => + UiTestGitHubData.Issues.FirstOrDefault(issue => Same(issue.Repository, repository) && issue.Number == number) is { } issue + ? Task.FromResult(issue) + : Task.FromException(new InvalidOperationException($"Issue {repository.DisplayName}#{number} is not in the UI test data.")); + + private static bool Same(RepositoryRef left, RepositoryRef right) => + string.Equals(left.DisplayName, right.DisplayName, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs b/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs new file mode 100644 index 0000000..9879752 --- /dev/null +++ b/src/Kontext/UiTesting/UiTestServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Kontext.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Kontext.UiTesting; + +internal static class UiTestServiceCollectionExtensions +{ + public const string EnvironmentVariable = "KONTEXT_UI_TEST"; + + public static bool IsEnabled => Environment.GetEnvironmentVariable(EnvironmentVariable) == "1"; + + /// Swaps GitHub and session start-up for fakes so UI tests need no sign-in, network or git. + public static IServiceCollection AddKontextUiTestFakes(this IServiceCollection services) + { + services.Replace(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Singleton()); + return services; + } +} diff --git a/src/Kontext/UiTesting/UiTestSessionCoordinator.cs b/src/Kontext/UiTesting/UiTestSessionCoordinator.cs new file mode 100644 index 0000000..a2dcae9 --- /dev/null +++ b/src/Kontext/UiTesting/UiTestSessionCoordinator.cs @@ -0,0 +1,10 @@ +using Kontext.Contracts; +using Kontext.Domain; + +namespace Kontext.UiTesting; + +/// Never clones, starts Claude or opens a terminal: UI tests must not touch the machine. +internal sealed class UiTestSessionCoordinator : IIssueSessionCoordinator +{ + public Task StartOrResumeAsync(GitHubIssue issue, CancellationToken cancellationToken = default) => Task.CompletedTask; +} diff --git a/src/Kontext/UiTesting/UiTestTokenStore.cs b/src/Kontext/UiTesting/UiTestTokenStore.cs new file mode 100644 index 0000000..a42c89b --- /dev/null +++ b/src/Kontext/UiTesting/UiTestTokenStore.cs @@ -0,0 +1,19 @@ +using Kontext.Contracts; +using Kontext.Kernel; + +namespace Kontext.UiTesting; + +internal sealed class UiTestTokenStore : IGitHubTokenStore +{ + public string? Load(string host) => host == GitHubHosts.Default ? "ui-test" : null; + + public void Save(string host, string token) + { + } + + public void Clear(string host) + { + } + + public string? FindEnterpriseHost() => null; +} diff --git a/src/Kontext/Views/GitChangesTemplateSelectors.cs b/src/Kontext/Views/GitChangesTemplateSelectors.cs index c4e065a..327674b 100644 --- a/src/Kontext/Views/GitChangesTemplateSelectors.cs +++ b/src/Kontext/Views/GitChangesTemplateSelectors.cs @@ -59,27 +59,3 @@ public sealed class SplitCellTemplateSelector : DataTemplateSelector protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item); } - -/// Picks the template (and so the background) for one line of a diff. -public sealed class DiffLineTemplateSelector : DataTemplateSelector -{ - public DataTemplate? AddedTemplate { get; set; } - - public DataTemplate? RemovedTemplate { get; set; } - - public DataTemplate? ContextTemplate { get; set; } - - public DataTemplate? HunkTemplate { get; set; } - - protected override DataTemplate? SelectTemplateCore(object item) => item is DiffLine line - ? line.Kind switch - { - DiffLineKind.Added => AddedTemplate, - DiffLineKind.Removed => RemovedTemplate, - DiffLineKind.HunkHeader => HunkTemplate, - _ => ContextTemplate, - } - : ContextTemplate; - - protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) => SelectTemplateCore(item); -} diff --git a/src/Kontext/Views/GitChangesView.xaml b/src/Kontext/Views/GitChangesView.xaml index c0490f6..0d9b035 100644 --- a/src/Kontext/Views/GitChangesView.xaml +++ b/src/Kontext/Views/GitChangesView.xaml @@ -19,14 +19,32 @@ + + 19 + + + + + + @@ -106,94 +126,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -