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..c756760 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: 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: Setup
+ uses: ./.github/actions/setup
- - 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,75 @@ 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: Architecture test
+ run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.ArchitectureTests.|FullyQualifiedName~.CompositionTests."
+
+ integration-test:
+ name: Run integration 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: Integration test
+ run: dotnet test --no-build --configuration Release --logger GitHubActions --filter "FullyQualifiedName~.IntegrationTests."
+
+ 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 --logger GitHubActions --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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-