WaitForHandoffCodeAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ if (_listener is null)
+ {
+ throw new InvalidOperationException("Listener not started.");
+ }
+
+ using var timeoutCts = new CancellationTokenSource(timeout);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);
+
+ try
+ {
+ using var client = await _listener.AcceptTcpClientAsync(linkedCts.Token);
+ await using var stream = client.GetStream();
+
+ var request = await ReadHeadersAsync(stream, linkedCts.Token);
+ var (path, query) = ParseRequestTarget(request);
+
+ var handoffCode = string.IsNullOrWhiteSpace(query) ? null : GetQueryParameter(query, "handoffCode");
+
+ // Return a small page so the browser doesn't show a connection reset.
+ var html = "Signed in" +
+ "Signed in
You can return to the app.
";
+
+ var bodyBytes = Encoding.UTF8.GetBytes(html);
+ var response = "HTTP/1.1 200 OK\r\n" +
+ "Content-Type: text/html; charset=utf-8\r\n" +
+ $"Content-Length: {bodyBytes.Length}\r\n" +
+ "Connection: close\r\n\r\n";
+
+ var headerBytes = Encoding.ASCII.GetBytes(response);
+ await stream.WriteAsync(headerBytes, linkedCts.Token);
+ await stream.WriteAsync(bodyBytes, linkedCts.Token);
+ await stream.FlushAsync(linkedCts.Token);
+
+ if (!string.Equals(path, _callbackPath, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException($"Unexpected callback path: '{path}'.");
+ }
+
+ if (string.IsNullOrWhiteSpace(handoffCode))
+ {
+ throw new InvalidOperationException("Missing handoffCode in loopback callback.");
+ }
+
+ return handoffCode;
+ }
+ catch (OperationCanceledException)
+ {
+ if (timeoutCts.IsCancellationRequested)
+ {
+ throw new TimeoutException("Timed out waiting for authentication callback.");
+ }
+
+ throw;
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ try
+ {
+ _listener?.Stop();
+ }
+ catch
+ {
+ // ignored
+ }
+
+ _listener = null;
+ return ValueTask.CompletedTask;
+ }
+
+ private static async Task ReadHeadersAsync(NetworkStream stream, CancellationToken cancellationToken)
+ {
+ // Read until end of headers (CRLF CRLF). Limit size to avoid abuse.
+ var buffer = new byte[4096];
+ var sb = new StringBuilder();
+
+ while (sb.Length < 32_768)
+ {
+ var read = await stream.ReadAsync(buffer, cancellationToken);
+ if (read <= 0)
+ {
+ break;
+ }
+
+ sb.Append(Encoding.ASCII.GetString(buffer, 0, read));
+
+ if (sb.ToString().Contains("\r\n\r\n", StringComparison.Ordinal))
+ {
+ break;
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static (string path, string? query) ParseRequestTarget(string requestHeaders)
+ {
+ // First line: GET /path?query HTTP/1.1
+ var firstLineEnd = requestHeaders.IndexOf("\r\n", StringComparison.Ordinal);
+ var firstLine = firstLineEnd >= 0 ? requestHeaders[..firstLineEnd] : requestHeaders;
+
+ var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ {
+ throw new InvalidOperationException("Invalid HTTP request received.");
+ }
+
+ var target = parts[1];
+ var qIndex = target.IndexOf('?', StringComparison.Ordinal);
+ if (qIndex < 0)
+ {
+ return (target, null);
+ }
+
+ return (target[..qIndex], target[(qIndex + 1)..]);
+ }
+
+ private static string? GetQueryParameter(string query, string key)
+ {
+ foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var kv = part.Split('=', 2);
+ if (kv.Length == 0)
+ {
+ continue;
+ }
+
+ var name = Uri.UnescapeDataString(kv[0]);
+ if (!string.Equals(name, key, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ return kv.Length == 2 ? Uri.UnescapeDataString(kv[1]) : string.Empty;
+ }
+
+ return null;
+ }
+}
diff --git a/JitHubV3/GlobalUsings.cs b/JitHubV3/GlobalUsings.cs
index a88f089..0f88eef 100644
--- a/JitHubV3/GlobalUsings.cs
+++ b/JitHubV3/GlobalUsings.cs
@@ -8,4 +8,7 @@
global using Microsoft.Extensions.Localization;
global using Microsoft.Extensions.Logging;
global using Microsoft.Extensions.Options;
+global using Uno.Extensions;
+global using Uno.Extensions.Authentication;
+global using Uno.Extensions.Navigation;
global using ApplicationExecutionState = Windows.ApplicationModel.Activation.ApplicationExecutionState;
diff --git a/JitHubV3/JitHubV3.csproj b/JitHubV3/JitHubV3.csproj
index d7f780f..a711da0 100644
--- a/JitHubV3/JitHubV3.csproj
+++ b/JitHubV3/JitHubV3.csproj
@@ -9,14 +9,27 @@
This keeps `dotnet build` on Windows from attempting Apple targets.
-->
- net10.0-windows10.0.26100;net10.0-desktop;net10.0-android
- net10.0-windows10.0.26100;net10.0-desktop;net10.0
+
+ false
+
+ net10.0-desktop;net10.0-windows10.0.26100;net10.0-android
+ net10.0-desktop;net10.0-windows10.0.26100
net10.0-ios;net10.0-maccatalyst;net10.0-desktop
net10.0-desktop
Exe
true
+
+ true
+
JitHubV3
@@ -87,6 +100,8 @@
+
+
diff --git a/JitHubV3/Presentation/Infrastructure/ActivatablePage.cs b/JitHubV3/Presentation/Infrastructure/ActivatablePage.cs
new file mode 100644
index 0000000..62ed1e9
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/ActivatablePage.cs
@@ -0,0 +1,51 @@
+namespace JitHubV3.Presentation;
+
+public class ActivatablePage : Page
+{
+ private IActivatableViewModel? _activeVm;
+
+ protected ActivatablePage()
+ {
+ DataContextChanged += (_, __) => TryActivateFromDataContext();
+ }
+
+ protected override void OnNavigatedTo(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
+ {
+ base.OnNavigatedTo(e);
+ TryActivateFromDataContext();
+ }
+
+ protected override void OnNavigatedFrom(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
+ {
+ DeactivateCurrent();
+ base.OnNavigatedFrom(e);
+ }
+
+ private void TryActivateFromDataContext()
+ {
+ if (DataContext is not IActivatableViewModel vm)
+ {
+ return;
+ }
+
+ if (ReferenceEquals(_activeVm, vm))
+ {
+ return;
+ }
+
+ DeactivateCurrent();
+ _activeVm = vm;
+
+ _ = vm.ActivateAsync();
+ }
+
+ private void DeactivateCurrent()
+ {
+ if (_activeVm is not null)
+ {
+ _activeVm.Deactivate();
+ }
+
+ _activeVm = null;
+ }
+}
diff --git a/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs
new file mode 100644
index 0000000..f7e9f0f
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs
@@ -0,0 +1,37 @@
+using System;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace JitHubV3.Presentation;
+
+public sealed class BoolToVisibilityConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ var visible = value is bool b && b;
+
+ if (parameter is string s && string.Equals(s, "Invert", StringComparison.OrdinalIgnoreCase))
+ {
+ visible = !visible;
+ }
+
+ return visible ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ if (value is not Visibility v)
+ {
+ return false;
+ }
+
+ var result = v == Visibility.Visible;
+
+ if (parameter is string s && string.Equals(s, "Invert", StringComparison.OrdinalIgnoreCase))
+ {
+ result = !result;
+ }
+
+ return result;
+ }
+}
diff --git a/JitHubV3/Presentation/Infrastructure/DataFreshnessState.cs b/JitHubV3/Presentation/Infrastructure/DataFreshnessState.cs
new file mode 100644
index 0000000..6ee6ab5
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/DataFreshnessState.cs
@@ -0,0 +1,8 @@
+namespace JitHubV3.Presentation;
+
+public enum DataFreshnessState
+{
+ Unknown = 0,
+ Cached = 1,
+ Fresh = 2,
+}
diff --git a/JitHubV3/Presentation/Infrastructure/IActivatableViewModel.cs b/JitHubV3/Presentation/Infrastructure/IActivatableViewModel.cs
new file mode 100644
index 0000000..1189c24
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/IActivatableViewModel.cs
@@ -0,0 +1,8 @@
+namespace JitHubV3.Presentation;
+
+public interface IActivatableViewModel
+{
+ Task ActivateAsync();
+
+ void Deactivate();
+}
diff --git a/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs
new file mode 100644
index 0000000..8f89cd6
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs
@@ -0,0 +1,155 @@
+namespace JitHubV3.Presentation;
+
+public sealed partial class StatusBarViewModel : ObservableObject
+{
+ private SynchronizationContext? _uiContext;
+
+ private string? _message;
+ private bool _isBusy;
+ private bool _isRefreshing;
+ private DataFreshnessState _freshness = DataFreshnessState.Unknown;
+ private DateTimeOffset? _lastUpdatedAt;
+
+ public void AttachToCurrentThread()
+ {
+ _uiContext ??= SynchronizationContext.Current;
+ }
+
+ public string? Message
+ {
+ get => _message;
+ set => SetProperty(ref _message, value);
+ }
+
+ public bool IsBusy
+ {
+ get => _isBusy;
+ set => SetProperty(ref _isBusy, value);
+ }
+
+ public bool IsRefreshing
+ {
+ get => _isRefreshing;
+ set => SetProperty(ref _isRefreshing, value);
+ }
+
+ public DataFreshnessState Freshness
+ {
+ get => _freshness;
+ set
+ {
+ if (SetProperty(ref _freshness, value))
+ {
+ OnPropertyChanged(nameof(FreshnessLabel));
+ }
+ }
+ }
+
+ public DateTimeOffset? LastUpdatedAt
+ {
+ get => _lastUpdatedAt;
+ set
+ {
+ if (SetProperty(ref _lastUpdatedAt, value))
+ {
+ OnPropertyChanged(nameof(LastUpdatedLabel));
+ }
+ }
+ }
+
+ public string FreshnessLabel => Freshness switch
+ {
+ DataFreshnessState.Cached => "Cached",
+ DataFreshnessState.Fresh => "Fresh",
+ _ => string.Empty,
+ };
+
+ public string LastUpdatedLabel
+ => LastUpdatedAt is null ? string.Empty : $"Updated {LastUpdatedAt:HH:mm:ss}";
+
+ public void Set(
+ string? message = null,
+ bool? isBusy = null,
+ bool? isRefreshing = null,
+ DataFreshnessState? freshness = null,
+ DateTimeOffset? lastUpdatedAt = null)
+ {
+ var ui = _uiContext;
+ if (ui is null)
+ {
+ // Best effort: if the caller is on UI thread and a context exists, capture it.
+ AttachToCurrentThread();
+ ui = _uiContext;
+ }
+
+ if (ui is null || ReferenceEquals(SynchronizationContext.Current, ui))
+ {
+ ApplySet(message, isBusy, isRefreshing, freshness, lastUpdatedAt);
+ return;
+ }
+
+ ui.Post(_ => ApplySet(message, isBusy, isRefreshing, freshness, lastUpdatedAt), null);
+ }
+
+ private void ApplySet(
+ string? message,
+ bool? isBusy,
+ bool? isRefreshing,
+ DataFreshnessState? freshness,
+ DateTimeOffset? lastUpdatedAt)
+ {
+ if (message is not null)
+ {
+ Message = message;
+ }
+
+ if (isBusy is not null)
+ {
+ IsBusy = isBusy.Value;
+ }
+
+ if (isRefreshing is not null)
+ {
+ IsRefreshing = isRefreshing.Value;
+ }
+
+ if (freshness is not null)
+ {
+ Freshness = freshness.Value;
+ }
+
+ if (lastUpdatedAt is not null)
+ {
+ LastUpdatedAt = lastUpdatedAt;
+ }
+ }
+
+ public void Clear()
+ {
+ var ui = _uiContext;
+ if (ui is null)
+ {
+ AttachToCurrentThread();
+ ui = _uiContext;
+ }
+
+ if (ui is null || ReferenceEquals(SynchronizationContext.Current, ui))
+ {
+ Message = null;
+ IsBusy = false;
+ IsRefreshing = false;
+ Freshness = DataFreshnessState.Unknown;
+ LastUpdatedAt = null;
+ return;
+ }
+
+ ui.Post(_ =>
+ {
+ Message = null;
+ IsBusy = false;
+ IsRefreshing = false;
+ Freshness = DataFreshnessState.Unknown;
+ LastUpdatedAt = null;
+ }, null);
+ }
+}
diff --git a/JitHubV3/Presentation/IssueConversationPage.xaml b/JitHubV3/Presentation/IssueConversationPage.xaml
new file mode 100644
index 0000000..e746060
--- /dev/null
+++ b/JitHubV3/Presentation/IssueConversationPage.xaml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/JitHubV3/Presentation/IssueConversationPage.xaml.cs b/JitHubV3/Presentation/IssueConversationPage.xaml.cs
new file mode 100644
index 0000000..55e7833
--- /dev/null
+++ b/JitHubV3/Presentation/IssueConversationPage.xaml.cs
@@ -0,0 +1,9 @@
+namespace JitHubV3.Presentation;
+
+public sealed partial class IssueConversationPage : ActivatablePage
+{
+ public IssueConversationPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/JitHubV3/Presentation/IssueConversationRouteData.cs b/JitHubV3/Presentation/IssueConversationRouteData.cs
new file mode 100644
index 0000000..fdb9918
--- /dev/null
+++ b/JitHubV3/Presentation/IssueConversationRouteData.cs
@@ -0,0 +1,8 @@
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHubV3.Presentation;
+
+public sealed record IssueConversationRouteData(
+ RepoKey Repo,
+ int IssueNumber,
+ string DisplayName);
diff --git a/JitHubV3/Presentation/IssueConversationViewModel.cs b/JitHubV3/Presentation/IssueConversationViewModel.cs
new file mode 100644
index 0000000..de4fe58
--- /dev/null
+++ b/JitHubV3/Presentation/IssueConversationViewModel.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using Microsoft.Extensions.Logging;
+
+namespace JitHubV3.Presentation;
+
+public partial class IssueConversationViewModel : ObservableObject, IActivatableViewModel
+{
+ private readonly IssueConversationRouteData _data;
+ private readonly IGitHubIssueConversationService _conversation;
+ private readonly ICacheEventBus _events;
+ private readonly IDispatcher _dispatcher;
+ private readonly INavigator _navigator;
+ private readonly StatusBarViewModel _statusBar;
+ private readonly ILogger _logger;
+
+ private CancellationTokenSource? _activeCts;
+ private IDisposable? _subscription;
+
+ private string? _status;
+ private IssueDetail? _issue;
+
+ public IssueConversationViewModel(
+ IssueConversationRouteData data,
+ IGitHubIssueConversationService conversation,
+ ICacheEventBus events,
+ StatusBarViewModel statusBar,
+ ILogger logger,
+ IDispatcher dispatcher,
+ INavigator navigator)
+ {
+ _data = data ?? throw new ArgumentNullException(nameof(data));
+ _conversation = conversation ?? throw new ArgumentNullException(nameof(conversation));
+ _events = events ?? throw new ArgumentNullException(nameof(events));
+ _statusBar = statusBar ?? throw new ArgumentNullException(nameof(statusBar));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _navigator = navigator ?? throw new ArgumentNullException(nameof(navigator));
+
+ Title = data.DisplayName;
+ GoBack = new AsyncRelayCommand(DoGoBack);
+ }
+
+ public string Title { get; }
+
+ public string? Status
+ {
+ get => _status;
+ set => SetProperty(ref _status, value);
+ }
+
+ public IssueDetail? Issue
+ {
+ get => _issue;
+ set => SetProperty(ref _issue, value);
+ }
+
+ public ObservableCollection Comments { get; } = new();
+
+ public ICommand GoBack { get; }
+
+ public Task ActivateAsync()
+ {
+ Deactivate();
+ _activeCts = new CancellationTokenSource();
+
+ _logger.LogInformation(
+ "Issue conversation activate: {Repo} #{Number}",
+ $"{_data.Repo.Owner}/{_data.Repo.Name}",
+ _data.IssueNumber);
+
+ return LoadAsync(_activeCts.Token);
+ }
+
+ public void Deactivate()
+ {
+ _activeCts?.Cancel();
+ _activeCts?.Dispose();
+ _activeCts = null;
+
+ _subscription?.Dispose();
+ _subscription = null;
+
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ }
+
+ private async Task LoadAsync(CancellationToken ct)
+ {
+ var sawCacheUpdate = false;
+
+ _subscription?.Dispose();
+ _subscription = _events.Subscribe(e =>
+ {
+ if (e.Kind != CacheEventKind.Updated)
+ {
+ return;
+ }
+
+ var isIssue = string.Equals(e.Key.Operation, "github.issue.get", StringComparison.Ordinal);
+ var isComments = string.Equals(e.Key.Operation, "github.issue.comments.list", StringComparison.Ordinal);
+ if (!isIssue && !isComments)
+ {
+ return;
+ }
+
+ if (!IsForCurrentIssue(e.Key, _data.Repo, _data.IssueNumber))
+ {
+ return;
+ }
+
+ _logger.LogDebug(
+ "Issue conversation cache updated; refreshing UI: {Repo} #{Number} op={Op}",
+ $"{_data.Repo.Owner}/{_data.Repo.Name}",
+ _data.IssueNumber,
+ e.Key.Operation);
+
+ sawCacheUpdate = true;
+
+ _ = _dispatcher.ExecuteAsync(async _ =>
+ {
+ try
+ {
+ if (isIssue)
+ {
+ Issue = await _conversation.GetIssueAsync(_data.Repo, _data.IssueNumber, RefreshMode.CacheOnly, ct);
+ }
+
+ if (isComments)
+ {
+ var cachedComments = await _conversation.GetCommentsAsync(_data.Repo, _data.IssueNumber, RefreshMode.CacheOnly, ct);
+ ObservableCollectionSync.SyncById(
+ Comments,
+ cachedComments,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+ }
+
+ Status = null;
+ _statusBar.Set(
+ message: "Conversation updated",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Fresh,
+ lastUpdatedAt: DateTimeOffset.Now);
+ }
+ catch
+ {
+ // Ignore UI update failures during shutdown/cancellation.
+ }
+ });
+ });
+
+ _statusBar.Set(
+ message: $"Loading {_data.DisplayName}…",
+ isBusy: true,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown,
+ lastUpdatedAt: null);
+
+ // Avoid jarring flicker: keep existing Issue/Comments when reloading.
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ if (Issue is null)
+ {
+ Status = "Loading...";
+ }
+
+ return ValueTask.CompletedTask;
+ });
+
+ // Try to show cached data immediately (if available) without forcing a clear.
+ try
+ {
+ var cachedIssue = await _conversation.GetIssueAsync(_data.Repo, _data.IssueNumber, RefreshMode.CacheOnly, ct);
+ var cachedComments = await _conversation.GetCommentsAsync(_data.Repo, _data.IssueNumber, RefreshMode.CacheOnly, ct);
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ Issue ??= cachedIssue;
+ ObservableCollectionSync.SyncById(
+ Comments,
+ cachedComments,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+
+ Status = null;
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: "Conversation (cached)",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Cached,
+ lastUpdatedAt: null);
+ }
+ catch
+ {
+ // CacheOnly may throw when empty; ignore.
+ }
+
+ try
+ {
+ var issue = await _conversation.GetIssueAsync(_data.Repo, _data.IssueNumber, RefreshMode.PreferCacheThenRefresh, ct);
+ var comments = await _conversation.GetCommentsAsync(_data.Repo, _data.IssueNumber, RefreshMode.PreferCacheThenRefresh, ct);
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ Issue = issue;
+ ObservableCollectionSync.SyncById(
+ Comments,
+ comments,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+
+ Status = null;
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: "Conversation loaded",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: sawCacheUpdate ? DataFreshnessState.Fresh : DataFreshnessState.Cached,
+ lastUpdatedAt: sawCacheUpdate ? DateTimeOffset.Now : null);
+
+ _logger.LogInformation(
+ "Issue conversation loaded: {Repo} #{Number} comments={Count} freshness={Freshness}",
+ $"{_data.Repo.Owner}/{_data.Repo.Name}",
+ _data.IssueNumber,
+ comments.Count,
+ sawCacheUpdate ? "Fresh" : "Cached");
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug(
+ "Issue conversation canceled: {Repo} #{Number}",
+ $"{_data.Repo.Owner}/{_data.Repo.Name}",
+ _data.IssueNumber);
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ // Ignore
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(
+ ex,
+ "Issue conversation failed: {Repo} #{Number}",
+ $"{_data.Repo.Owner}/{_data.Repo.Name}",
+ _data.IssueNumber);
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ Status = $"Failed to load conversation: {ex.Message}";
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: "Failed to load conversation",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown);
+ }
+ }
+
+ private static bool IsForCurrentIssue(CacheKey key, RepoKey repo, int issueNumber)
+ {
+ var owner = key.GetParameterValue("owner");
+ var repoName = key.GetParameterValue("repo");
+ var num = key.GetParameterValue("issueNumber");
+
+ return string.Equals(owner, repo.Owner, StringComparison.Ordinal)
+ && string.Equals(repoName, repo.Name, StringComparison.Ordinal)
+ && string.Equals(num, issueNumber.ToString(), StringComparison.Ordinal);
+ }
+
+ private Task DoGoBack(CancellationToken ct)
+ => _navigator.NavigateBackAsync(this, cancellation: ct);
+}
diff --git a/JitHubV3/Presentation/IssuesPage.xaml b/JitHubV3/Presentation/IssuesPage.xaml
new file mode 100644
index 0000000..6a0a598
--- /dev/null
+++ b/JitHubV3/Presentation/IssuesPage.xaml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/JitHubV3/Presentation/IssuesPage.xaml.cs b/JitHubV3/Presentation/IssuesPage.xaml.cs
new file mode 100644
index 0000000..6edd6a0
--- /dev/null
+++ b/JitHubV3/Presentation/IssuesPage.xaml.cs
@@ -0,0 +1,96 @@
+namespace JitHubV3.Presentation;
+
+public sealed partial class IssuesPage : ActivatablePage
+{
+ private ScrollViewer? _scrollViewer;
+
+ public IssuesPage()
+ {
+ InitializeComponent();
+ }
+
+ private void OnIssuesListLoaded(object sender, RoutedEventArgs e)
+ {
+ if (_scrollViewer is not null)
+ {
+ return;
+ }
+
+ if (IssuesList is null)
+ {
+ return;
+ }
+
+ _scrollViewer = FindScrollViewer(IssuesList);
+ if (_scrollViewer is not null)
+ {
+ _scrollViewer.ViewChanged += OnListViewScrollChanged;
+ }
+ }
+
+ private void OnIssuesListUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (_scrollViewer is not null)
+ {
+ _scrollViewer.ViewChanged -= OnListViewScrollChanged;
+ _scrollViewer = null;
+ }
+ }
+
+ private void OnListViewScrollChanged(object? sender, ScrollViewerViewChangedEventArgs e)
+ {
+ if (DataContext is not IssuesViewModel vm)
+ {
+ return;
+ }
+
+ var sv = _scrollViewer;
+ if (sv is null)
+ {
+ return;
+ }
+
+ // Trigger incremental loading when near the bottom.
+ // Threshold in DIPs: small enough to feel immediate, large enough to avoid repeated triggering.
+ const double threshold = 240;
+ var remaining = sv.ExtentHeight - (sv.VerticalOffset + sv.ViewportHeight);
+ if (remaining <= threshold)
+ {
+ _ = vm.TryLoadNextPageAsync();
+ }
+ }
+
+ private static ScrollViewer? FindScrollViewer(DependencyObject root)
+ {
+ if (root is ScrollViewer sv)
+ {
+ return sv;
+ }
+
+ var count = VisualTreeHelper.GetChildrenCount(root);
+ for (var i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(root, i);
+ var found = FindScrollViewer(child);
+ if (found is not null)
+ {
+ return found;
+ }
+ }
+
+ return null;
+ }
+
+ private async void OnIssueClicked(object sender, ItemClickEventArgs e)
+ {
+ if (DataContext is not IssuesViewModel vm)
+ {
+ return;
+ }
+
+ if (e.ClickedItem is JitHub.GitHub.Abstractions.Models.IssueSummary issue)
+ {
+ await vm.OpenIssueAsync(issue);
+ }
+ }
+}
diff --git a/JitHubV3/Presentation/IssuesViewModel.cs b/JitHubV3/Presentation/IssuesViewModel.cs
new file mode 100644
index 0000000..9a8b543
--- /dev/null
+++ b/JitHubV3/Presentation/IssuesViewModel.cs
@@ -0,0 +1,313 @@
+using System.Collections.ObjectModel;
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Polling;
+using JitHub.GitHub.Abstractions.Queries;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using Microsoft.Extensions.Logging;
+
+namespace JitHubV3.Presentation;
+
+public partial class IssuesViewModel : ObservableObject
+ , IActivatableViewModel
+{
+ private readonly IGitHubIssueService _issueService;
+ private readonly IGitHubIssuePollingService _polling;
+ private readonly ICacheEventBus _events;
+ private readonly IDispatcher _dispatcher;
+ private readonly INavigator _navigator;
+ private readonly StatusBarViewModel _statusBar;
+ private readonly ILogger _logger;
+
+ private readonly RepoRouteData _repo;
+
+ private CancellationTokenSource? _activeCts;
+ private IDisposable? _subscription;
+ private string? _status;
+
+ private readonly IssueQuery _query = new(IssueStateFilter.Open);
+ private readonly int _pageSize = 30;
+ private PageRequest _firstPage;
+ private PageRequest? _nextPage;
+ private readonly List _loaded = new();
+
+ private int _loadMoreGate;
+
+ public IssuesViewModel(
+ RepoRouteData repo,
+ IGitHubIssueService issueService,
+ IGitHubIssuePollingService polling,
+ ICacheEventBus events,
+ StatusBarViewModel statusBar,
+ ILogger logger,
+ IDispatcher dispatcher,
+ INavigator navigator)
+ {
+ _repo = repo;
+ _issueService = issueService;
+ _polling = polling;
+ _events = events;
+ _statusBar = statusBar;
+ _logger = logger;
+ _dispatcher = dispatcher;
+ _navigator = navigator;
+
+ Title = repo.DisplayName;
+ Logout = new AsyncRelayCommand(DoLogout);
+ GoBack = new AsyncRelayCommand(DoGoBack);
+
+ _firstPage = PageRequest.FirstPage(_pageSize);
+ }
+
+ public string Title { get; }
+
+ public string? Status
+ {
+ get => _status;
+ set => SetProperty(ref _status, value);
+ }
+
+ public ObservableCollection Issues { get; } = new();
+
+ public ICommand Logout { get; }
+
+ public ICommand GoBack { get; }
+
+ public Task OpenIssueAsync(IssueSummary issue)
+ {
+ var display = $"{_repo.DisplayName} #{issue.Number}";
+ var data = new IssueConversationRouteData(_repo.Repo, issue.Number, display);
+ return _navigator.NavigateViewModelAsync(this, data: data);
+ }
+
+ public async Task ActivateAsync()
+ {
+ Deactivate();
+
+ _activeCts = new CancellationTokenSource();
+ var ct = _activeCts.Token;
+
+ var sawCacheUpdate = false;
+
+ _statusBar.Set(
+ message: $"Loading issues for {_repo.DisplayName}…",
+ isBusy: true,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown,
+ lastUpdatedAt: null);
+
+ Status = "Loading...";
+
+ var query = _query;
+ var page = _firstPage;
+
+ _loaded.Clear();
+ _nextPage = null;
+
+ _logger.LogInformation("Issues activate: {Repo} state={State}", _repo.DisplayName, query.State);
+
+ _subscription = _events.Subscribe(e =>
+ {
+ if (e.Kind != CacheEventKind.Updated)
+ {
+ return;
+ }
+
+ if (!string.Equals(e.Key.Operation, "github.issues.list", StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ if (!IsForCurrentRequest(e.Key, _repo.Repo, query, page))
+ {
+ return;
+ }
+
+ _logger.LogDebug("Issues cache updated; refreshing UI: {Repo}", _repo.DisplayName);
+ sawCacheUpdate = true;
+
+ // Re-read from cache and update UI without flicker.
+ _ = _dispatcher.ExecuteAsync(async _ =>
+ {
+ try
+ {
+ var cached = await _issueService.GetIssuesAsync(_repo.Repo, query, page, RefreshMode.CacheOnly, ct);
+
+ // Keep already-loaded additional pages, but refresh the first page.
+ var cachedIds = cached.Items.Select(i => i.Id).ToHashSet();
+ var merged = cached.Items.Concat(_loaded.Where(i => !cachedIds.Contains(i.Id))).ToArray();
+ SetLoaded(merged, next: _nextPage);
+
+ Status = null;
+
+ _statusBar.Set(
+ message: "Issues updated",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Fresh,
+ lastUpdatedAt: DateTimeOffset.Now);
+ }
+ catch
+ {
+ // Ignore UI update failures during shutdown/cancellation.
+ }
+ });
+ });
+
+ try
+ {
+ var first = await _issueService.GetIssuesAsync(_repo.Repo, query, page, RefreshMode.PreferCacheThenRefresh, ct);
+ SetLoaded(first.Items, next: first.Next);
+ Status = Issues.Count == 0 ? "No issues found." : null;
+
+ _logger.LogInformation("Issues loaded: {Count} (polling every {Interval}s)", Issues.Count, 10);
+
+ _statusBar.Set(
+ message: Issues.Count == 0 ? "No issues" : $"{Issues.Count} issues",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: sawCacheUpdate ? DataFreshnessState.Fresh : DataFreshnessState.Cached,
+ lastUpdatedAt: sawCacheUpdate ? DateTimeOffset.Now : null);
+
+ _ = _polling.StartIssuesPollingAsync(
+ _repo.Repo,
+ query,
+ page,
+ new PollingRequest(TimeSpan.FromSeconds(10)),
+ ct);
+ }
+ catch (OperationCanceledException)
+ {
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ }
+ catch (Exception ex)
+ {
+ Status = $"Failed to load issues: {ex.Message}";
+ _statusBar.Set(
+ message: "Failed to load issues",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown);
+ }
+ }
+
+ public Task TryLoadNextPageAsync()
+ {
+ if (_nextPage is not { } next)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (_activeCts is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (Interlocked.Exchange(ref _loadMoreGate, 1) == 1)
+ {
+ return Task.CompletedTask;
+ }
+
+ return LoadNextPageCoreAsync(next);
+ }
+
+ private async Task LoadNextPageCoreAsync(PageRequest next)
+ {
+ try
+ {
+ var ct = _activeCts?.Token ?? CancellationToken.None;
+ var result = await _issueService.GetIssuesAsync(_repo.Repo, _query, next, RefreshMode.PreferCacheThenRefresh, ct);
+
+ if (result.Items.Count > 0)
+ {
+ var existingIds = _loaded.Select(i => i.Id).ToHashSet();
+ foreach (var item in result.Items)
+ {
+ if (existingIds.Add(item.Id))
+ {
+ _loaded.Add(item);
+ }
+ }
+
+ ApplyIssues(_loaded);
+ }
+
+ _nextPage = result.Next;
+ }
+ catch (OperationCanceledException)
+ {
+ // ignore
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to load next issues page for {Repo}", _repo.DisplayName);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _loadMoreGate, 0);
+ }
+ }
+
+ private Task DoGoBack(CancellationToken token)
+ => _navigator.NavigateBackAsync(this, cancellation: token);
+
+ public void Deactivate()
+ {
+ _activeCts?.Cancel();
+ _activeCts?.Dispose();
+ _activeCts = null;
+
+ _subscription?.Dispose();
+ _subscription = null;
+
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ }
+
+ private void SetLoaded(IReadOnlyList items, PageRequest? next)
+ {
+ _loaded.Clear();
+ _loaded.AddRange(items);
+ _nextPage = next;
+ ApplyIssues(_loaded);
+ }
+
+ private void ApplyIssues(IReadOnlyList items)
+ {
+ ObservableCollectionSync.SyncById(
+ Issues,
+ items,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+ }
+
+ private static bool IsForCurrentRequest(CacheKey key, RepoKey repo, IssueQuery query, PageRequest page)
+ {
+ var owner = key.GetParameterValue("owner");
+ var repoName = key.GetParameterValue("repo");
+ var state = key.GetParameterValue("state");
+ var search = key.GetParameterValueOrEmpty("search");
+ var pageSize = key.GetParameterValue("pageSize");
+ var pageNumber = key.GetParameterValueOrEmpty("pageNumber");
+ var cursor = key.GetParameterValueOrEmpty("cursor");
+
+ var expectedSearch = string.IsNullOrWhiteSpace(query.SearchText) ? string.Empty : query.SearchText.Trim();
+ var expectedPageNumber = page.PageNumber?.ToString() ?? string.Empty;
+ var expectedCursor = page.Cursor ?? string.Empty;
+
+ return string.Equals(owner, repo.Owner, StringComparison.Ordinal)
+ && string.Equals(repoName, repo.Name, StringComparison.Ordinal)
+ && string.Equals(state, query.State.ToString(), StringComparison.Ordinal)
+ && string.Equals(search, expectedSearch, StringComparison.Ordinal)
+ && string.Equals(pageSize, page.PageSize.ToString(), StringComparison.Ordinal)
+ && string.Equals(pageNumber, expectedPageNumber, StringComparison.Ordinal)
+ && string.Equals(cursor, expectedCursor, StringComparison.Ordinal);
+ }
+
+ private async Task DoLogout(CancellationToken token)
+ {
+ // Minimal: rely on auth service owned by MainViewModel flow.
+ await _navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.ClearBackStack);
+ }
+}
diff --git a/JitHubV3/Presentation/MainPage.xaml b/JitHubV3/Presentation/MainPage.xaml
index 27ea76e..3d86940 100644
--- a/JitHubV3/Presentation/MainPage.xaml
+++ b/JitHubV3/Presentation/MainPage.xaml
@@ -1,4 +1,4 @@
-
-
-
+
+
+
+
+
+
+
+
+
-
+
+
+
-
+
+
-
-
-
-
-
-
-
+ Orientation="Horizontal"
+ Spacing="12">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/JitHubV3/Presentation/MainPage.xaml.cs b/JitHubV3/Presentation/MainPage.xaml.cs
index 905acdc..b5ce3a1 100644
--- a/JitHubV3/Presentation/MainPage.xaml.cs
+++ b/JitHubV3/Presentation/MainPage.xaml.cs
@@ -1,9 +1,22 @@
namespace JitHubV3.Presentation;
-public sealed partial class MainPage : Page
+public sealed partial class MainPage : ActivatablePage
{
public MainPage()
{
this.InitializeComponent();
}
+
+ private async void OnRepoClicked(object sender, ItemClickEventArgs e)
+ {
+ if (DataContext is not MainViewModel vm)
+ {
+ return;
+ }
+
+ if (e.ClickedItem is JitHub.GitHub.Abstractions.Models.RepositorySummary repo)
+ {
+ await vm.OpenRepoAsync(repo);
+ }
+ }
}
diff --git a/JitHubV3/Presentation/MainViewModel.cs b/JitHubV3/Presentation/MainViewModel.cs
index 3204282..c8f0d4d 100644
--- a/JitHubV3/Presentation/MainViewModel.cs
+++ b/JitHubV3/Presentation/MainViewModel.cs
@@ -2,23 +2,33 @@ namespace JitHubV3.Presentation;
using System.Collections.ObjectModel;
using System.Linq;
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using Microsoft.Extensions.Logging;
+using System.Threading;
public partial class MainViewModel : ObservableObject
+ , IActivatableViewModel
{
+ private readonly ILogger _logger;
+ private readonly IDispatcher _dispatcher;
private IAuthenticationService _authentication;
-
- private readonly Services.GitHub.IGitHubApi _gitHubApi;
+ private readonly IGitHubRepositoryService _repositoryService;
+ private readonly ICacheEventBus _events;
+ private readonly StatusBarViewModel _statusBar;
private INavigator _navigator;
- private string? _name;
- private string? _repoStatus;
+ private CancellationTokenSource? _activeCts;
+ private IDisposable? _subscription;
- public string? Name
- {
- get => _name;
- set => SetProperty(ref _name, value);
- }
+ private string? _repoStatus;
+ private bool _isLoading;
+ private int _loadGate;
+ private int _repositoryCount;
+ private ObservableCollection _repositories = new();
public string? RepoStatus
{
@@ -26,71 +36,217 @@ public string? RepoStatus
set => SetProperty(ref _repoStatus, value);
}
- public ObservableCollection PrivateRepos { get; } = new();
+ public bool IsLoading
+ {
+ get => _isLoading;
+ set => SetProperty(ref _isLoading, value);
+ }
+
+ public int RepositoryCount
+ {
+ get => _repositoryCount;
+ set => SetProperty(ref _repositoryCount, value);
+ }
+
+ public ObservableCollection Repositories
+ {
+ get => _repositories;
+ set => SetProperty(ref _repositories, value);
+ }
public MainViewModel(
IStringLocalizer localizer,
IOptions appInfo,
+ ILogger logger,
+ IDispatcher dispatcher,
IAuthenticationService authentication,
- Services.GitHub.IGitHubApi gitHubApi,
+ IGitHubRepositoryService repositoryService,
+ ICacheEventBus events,
+ StatusBarViewModel statusBar,
INavigator navigator)
{
_navigator = navigator;
+ _logger = logger;
+ _dispatcher = dispatcher;
_authentication = authentication;
- _gitHubApi = gitHubApi;
+ _repositoryService = repositoryService;
+ _events = events;
+ _statusBar = statusBar;
Title = "Main";
Title += $" - {localizer["ApplicationName"]}";
Title += $" - {appInfo?.Value?.Environment}";
- GoToSecond = new AsyncRelayCommand(GoToSecondView);
- GoToMarkdownTest = new AsyncRelayCommand(GoToMarkdownTestView);
- LoadPrivateRepos = new AsyncRelayCommand(DoLoadPrivateRepos);
Logout = new AsyncRelayCommand(DoLogout);
}
public string? Title { get; }
- public ICommand GoToSecond { get; }
+ public ICommand Logout { get; }
+
+ public async Task LoadReposAsync(CancellationToken ct)
+ {
+ if (Interlocked.Exchange(ref _loadGate, 1) == 1)
+ {
+ return;
+ }
- public ICommand GoToMarkdownTest { get; }
+ var sawCacheUpdate = false;
+ _subscription?.Dispose();
+ _subscription = _events.Subscribe(e =>
+ {
+ if (e.Kind != CacheEventKind.Updated)
+ {
+ return;
+ }
- public ICommand LoadPrivateRepos { get; }
+ if (!string.Equals(e.Key.Operation, "github.repos.mine", StringComparison.Ordinal))
+ {
+ return;
+ }
- public ICommand Logout { get; }
+ sawCacheUpdate = true;
+ _ = _dispatcher.ExecuteAsync(async _ =>
+ {
+ try
+ {
+ var cached = await _repositoryService.GetMyRepositoriesAsync(RefreshMode.CacheOnly, ct);
+ SyncById(
+ Repositories,
+ cached,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
- private async Task GoToSecondView()
- {
- await _navigator.NavigateViewModelAsync(this, data: new Entity(Name!));
- }
+ RepositoryCount = Repositories.Count;
- private async Task GoToMarkdownTestView()
- {
- await _navigator.NavigateViewModelAsync(this);
- }
+ RepoStatus = cached.Count switch
+ {
+ 0 => "No repos found.",
+ _ => $"Loaded {cached.Count} repos."
+ };
- private async Task DoLoadPrivateRepos()
- {
- RepoStatus = "Loading...";
- PrivateRepos.Clear();
+ _statusBar.Set(
+ message: "Repositories updated",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Fresh,
+ lastUpdatedAt: DateTimeOffset.Now);
+ }
+ catch
+ {
+ // Ignore UI update failures during shutdown/cancellation.
+ }
+ });
+ });
+
+ _statusBar.Set(
+ message: "Loading repositories…",
+ isBusy: true,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown,
+ lastUpdatedAt: null);
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ IsLoading = true;
+ RepoStatus = "Loading...";
+ return ValueTask.CompletedTask;
+ });
try
{
- var repos = await _gitHubApi.GetMyPrivateReposAsync(CancellationToken.None);
- foreach (var repo in repos.Take(10))
- {
- PrivateRepos.Add(repo.FullName);
- }
+ _logger.LogInformation("Loading repositories (RefreshMode={RefreshMode})", RefreshMode.PreferCacheThenRefresh);
+ var repos = await _repositoryService.GetMyRepositoriesAsync(RefreshMode.PreferCacheThenRefresh, ct);
- RepoStatus = repos.Count switch
+ _logger.LogInformation("Repositories loaded: {Count}", repos.Count);
+ await _dispatcher.ExecuteAsync(_ =>
{
- 0 => "No private repos found.",
- _ => $"Loaded {repos.Count} private repos (showing first {Math.Min(10, repos.Count)})."
- };
+ SyncById(
+ Repositories,
+ repos,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+
+ RepositoryCount = Repositories.Count;
+
+ RepoStatus = repos.Count switch
+ {
+ 0 => "No repos found.",
+ _ => $"Loaded {repos.Count} repos."
+ };
+
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: repos.Count == 0 ? "No repositories" : $"{repos.Count} repositories",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: sawCacheUpdate ? DataFreshnessState.Fresh : DataFreshnessState.Cached,
+ lastUpdatedAt: sawCacheUpdate ? DateTimeOffset.Now : null);
+ }
+ catch (OperationCanceledException)
+ {
+ _statusBar.Set(isBusy: false, isRefreshing: false);
}
catch (Exception ex)
{
- RepoStatus = $"Failed to load repos: {ex.Message}";
+ _logger.LogError(ex, "Failed to load repositories");
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ RepoStatus = $"Failed to load repos: {ex.Message}";
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: "Failed to load repositories",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown);
+ }
+ finally
+ {
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ IsLoading = false;
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ Interlocked.Exchange(ref _loadGate, 0);
}
}
+ public Task ActivateAsync()
+ {
+ Deactivate();
+ _activeCts = new CancellationTokenSource();
+ return LoadReposAsync(_activeCts.Token);
+ }
+
+ public void Deactivate()
+ {
+ _activeCts?.Cancel();
+ _activeCts?.Dispose();
+ _activeCts = null;
+
+ _subscription?.Dispose();
+ _subscription = null;
+
+ Interlocked.Exchange(ref _loadGate, 0);
+ }
+
+ private static void SyncById(
+ ObservableCollection target,
+ IReadOnlyList source,
+ Func getId,
+ Func shouldReplace)
+ => ObservableCollectionSync.SyncById(target, source, getId, shouldReplace);
+
+ public Task OpenRepoAsync(RepositorySummary repo)
+ {
+ var key = new RepoKey(repo.OwnerLogin, repo.Name);
+ var data = new RepoRouteData(key, DisplayName: $"{repo.OwnerLogin}/{repo.Name}");
+ return _navigator.NavigateViewModelAsync(this, data: data);
+ }
+
public async Task DoLogout(CancellationToken token)
{
try
@@ -99,8 +255,11 @@ public async Task DoLogout(CancellationToken token)
}
finally
{
+ Deactivate();
+ _statusBar.Clear();
RepoStatus = null;
- PrivateRepos.Clear();
+ Repositories = new ObservableCollection();
+ RepositoryCount = 0;
await _navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.ClearBackStack);
}
}
diff --git a/JitHubV3/Presentation/MarkdownTestPage.xaml b/JitHubV3/Presentation/MarkdownTestPage.xaml
index de58a2d..654a9fb 100644
--- a/JitHubV3/Presentation/MarkdownTestPage.xaml
+++ b/JitHubV3/Presentation/MarkdownTestPage.xaml
@@ -11,11 +11,16 @@
NavigationCacheMode="Required"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
-
+
-
+
+
+
MarkdownThemeEngine.Resolve(ThemeVariant);
+ public ICommand GoBack { get; }
+
+ private Task DoGoBack(CancellationToken ct)
+ => _navigator.NavigateBackAsync(this, cancellation: ct);
+
private void OnForceRtlChanged(bool value)
{
// Preserve edits per-mode so you can flip back and forth while testing.
diff --git a/JitHubV3/Presentation/RepoRouteData.cs b/JitHubV3/Presentation/RepoRouteData.cs
new file mode 100644
index 0000000..08f8697
--- /dev/null
+++ b/JitHubV3/Presentation/RepoRouteData.cs
@@ -0,0 +1,7 @@
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHubV3.Presentation;
+
+public sealed record RepoRouteData(
+ RepoKey Repo,
+ string DisplayName);
diff --git a/JitHubV3/Presentation/SecondPage.xaml b/JitHubV3/Presentation/SecondPage.xaml
index 1587ca9..dd646e5 100644
--- a/JitHubV3/Presentation/SecondPage.xaml
+++ b/JitHubV3/Presentation/SecondPage.xaml
@@ -10,11 +10,18 @@
+
-
+
+
_navigator.NavigateBackAsync(this, cancellation: ct);
}
diff --git a/JitHubV3/Presentation/Shell.IContentControlProvider.cs b/JitHubV3/Presentation/Shell.IContentControlProvider.cs
new file mode 100644
index 0000000..c95a736
--- /dev/null
+++ b/JitHubV3/Presentation/Shell.IContentControlProvider.cs
@@ -0,0 +1,9 @@
+#if WINDOWS || __SKIA__ || __ANDROID__ || __IOS__ || __MACOS__ || __MACCATALYST__
+using Uno.Extensions.Navigation.UI;
+
+namespace JitHubV3.Presentation;
+
+public sealed partial class Shell : IContentControlProvider
+{
+}
+#endif
diff --git a/JitHubV3/Presentation/Shell.xaml b/JitHubV3/Presentation/Shell.xaml
index 6dfa01b..12fdb3e 100644
--- a/JitHubV3/Presentation/Shell.xaml
+++ b/JitHubV3/Presentation/Shell.xaml
@@ -8,29 +8,76 @@
mc:Ignorable="d"
d:DesignHeight="300"
d:DesignWidth="400">
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/JitHubV3/Presentation/Shell.xaml.cs b/JitHubV3/Presentation/Shell.xaml.cs
index 051fb03..68a201a 100644
--- a/JitHubV3/Presentation/Shell.xaml.cs
+++ b/JitHubV3/Presentation/Shell.xaml.cs
@@ -1,10 +1,22 @@
namespace JitHubV3.Presentation;
-public sealed partial class Shell : UserControl, IContentControlProvider
+public sealed partial class Shell : UserControl
{
public Shell()
{
this.InitializeComponent();
+ Loaded += OnLoaded;
}
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is ShellViewModel vm)
+ {
+ vm.StatusBar.AttachToCurrentThread();
+ }
+ }
+
+#if WINDOWS || __SKIA__ || __ANDROID__ || __IOS__ || __MACOS__ || __MACCATALYST__
public ContentControl ContentControl => Splash;
+#endif
}
diff --git a/JitHubV3/Presentation/ShellViewModel.cs b/JitHubV3/Presentation/ShellViewModel.cs
index 47c74f9..d683717 100644
--- a/JitHubV3/Presentation/ShellViewModel.cs
+++ b/JitHubV3/Presentation/ShellViewModel.cs
@@ -6,18 +6,22 @@ public class ShellViewModel
private readonly INavigator _navigator;
+ public StatusBarViewModel StatusBar { get; }
public ShellViewModel(
IAuthenticationService authentication,
- INavigator navigator)
+ INavigator navigator,
+ StatusBarViewModel statusBar)
{
_navigator = navigator;
_authentication = authentication;
+ StatusBar = statusBar;
_authentication.LoggedOut += LoggedOut;
}
private async void LoggedOut(object? sender, EventArgs e)
{
+ StatusBar.Clear();
await _navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.ClearBackStack);
}
}
diff --git a/JitHubV3/ReadMe.md b/JitHubV3/ReadMe.md
index 117d2be..a2ef5c2 100644
--- a/JitHubV3/ReadMe.md
+++ b/JitHubV3/ReadMe.md
@@ -12,6 +12,10 @@ For more information on how to use the Uno.Sdk or upgrade Uno Platform packages
- [Golden render tests](../docs/markdown-rendering-architecture.md)
- [Benchmarks](../docs/benchmarks.md)
+## GitHub (architecture)
+
+- [GitHub service layer + data/networking architecture](../docs/github-service-layer-architecture.md)
+
## GitHub OAuth (local dev)
This solution uses a **server-mediated** GitHub OAuth flow.
diff --git a/JitHubV3/Services/GitHub/IGitHubApi.cs b/JitHubV3/Services/GitHub/IGitHubApi.cs
deleted file mode 100644
index 9abcc9e..0000000
--- a/JitHubV3/Services/GitHub/IGitHubApi.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace JitHubV3.Services.GitHub;
-
-public interface IGitHubApi
-{
- Task> GetMyPrivateReposAsync(CancellationToken cancellationToken);
-}
-
-public sealed record GitHubRepo(string Name, string FullName, string HtmlUrl, bool Private);
diff --git a/JitHubV3/Services/GitHub/OctokitGitHubApi.cs b/JitHubV3/Services/GitHub/OctokitGitHubApi.cs
deleted file mode 100644
index 1f3b4b8..0000000
--- a/JitHubV3/Services/GitHub/OctokitGitHubApi.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Octokit;
-using Uno.Extensions.Authentication;
-
-namespace JitHubV3.Services.GitHub;
-
-public sealed class OctokitGitHubApi : IGitHubApi
-{
- private readonly ITokenCache _tokenCache;
-
- public OctokitGitHubApi(ITokenCache tokenCache)
- {
- _tokenCache = tokenCache;
- }
-
- public async Task> GetMyPrivateReposAsync(CancellationToken cancellationToken)
- {
- var tokens = await _tokenCache.GetAsync(cancellationToken);
- if (tokens is null || !tokens.TryGetValue("access_token", out var accessToken) || string.IsNullOrWhiteSpace(accessToken))
- {
- throw new InvalidOperationException("Missing access_token. Please login first.");
- }
-
- var client = new GitHubClient(new ProductHeaderValue("JitHubV3"))
- {
- Credentials = new Credentials(accessToken)
- };
-
- // Request only private repos; GitHub may still return other repos depending on token scopes.
- var request = new RepositoryRequest
- {
- Visibility = RepositoryRequestVisibility.Private,
- Affiliation = RepositoryAffiliation.Owner
- };
-
- var repos = await client.Repository.GetAllForCurrent(request);
-
- return repos
- .Where(r => r.Private)
- .Select(r => new GitHubRepo(
- Name: r.Name,
- FullName: r.FullName,
- HtmlUrl: r.HtmlUrl,
- Private: r.Private))
- .ToArray();
- }
-}
diff --git a/JitHubV3/Services/GitHub/PasswordVaultSecretStore.Windows.cs b/JitHubV3/Services/GitHub/PasswordVaultSecretStore.Windows.cs
new file mode 100644
index 0000000..42c644f
--- /dev/null
+++ b/JitHubV3/Services/GitHub/PasswordVaultSecretStore.Windows.cs
@@ -0,0 +1,69 @@
+#if WINDOWS
+using JitHub.GitHub.Abstractions.Security;
+using Windows.Security.Credentials;
+
+namespace JitHubV3.Services.GitHub;
+
+public sealed class PasswordVaultSecretStore : ISecretStore
+{
+ private const string ResourceName = "JitHubV3";
+
+ public ValueTask GetAsync(string key, CancellationToken cancellationToken)
+ {
+ if (key is null)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+
+ var vault = new PasswordVault();
+ try
+ {
+ var credential = vault.Retrieve(ResourceName, key);
+ credential.RetrievePassword();
+ return ValueTask.FromResult(credential.Password);
+ }
+ catch
+ {
+ return ValueTask.FromResult(null);
+ }
+ }
+
+ public ValueTask SetAsync(string key, string value, CancellationToken cancellationToken)
+ {
+ if (key is null)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+
+ if (value is null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ var vault = new PasswordVault();
+ vault.Add(new PasswordCredential(ResourceName, key, value));
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask RemoveAsync(string key, CancellationToken cancellationToken)
+ {
+ if (key is null)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+
+ var vault = new PasswordVault();
+ try
+ {
+ var existing = vault.Retrieve(ResourceName, key);
+ vault.Remove(existing);
+ }
+ catch
+ {
+ // ignore
+ }
+
+ return ValueTask.CompletedTask;
+ }
+}
+#endif
diff --git a/JitHubV3/Services/GitHub/PlatformSecretStore.cs b/JitHubV3/Services/GitHub/PlatformSecretStore.cs
new file mode 100644
index 0000000..ac4a876
--- /dev/null
+++ b/JitHubV3/Services/GitHub/PlatformSecretStore.cs
@@ -0,0 +1,35 @@
+using JitHub.GitHub.Abstractions.Security;
+
+namespace JitHubV3.Services.GitHub;
+
+public sealed class PlatformSecretStore : ISecretStore
+{
+ private readonly ISecretStore _inner;
+
+ public PlatformSecretStore()
+ {
+#if WINDOWS
+ _inner = new PasswordVaultSecretStore();
+#else
+ _inner = new UnsupportedSecretStore();
+#endif
+ }
+
+ public ValueTask GetAsync(string key, CancellationToken cancellationToken) => _inner.GetAsync(key, cancellationToken);
+
+ public ValueTask SetAsync(string key, string value, CancellationToken cancellationToken) => _inner.SetAsync(key, value, cancellationToken);
+
+ public ValueTask RemoveAsync(string key, CancellationToken cancellationToken) => _inner.RemoveAsync(key, cancellationToken);
+
+ private sealed class UnsupportedSecretStore : ISecretStore
+ {
+ public ValueTask GetAsync(string key, CancellationToken cancellationToken)
+ => throw new PlatformNotSupportedException("No platform secret store configured for this target.");
+
+ public ValueTask SetAsync(string key, string value, CancellationToken cancellationToken)
+ => throw new PlatformNotSupportedException("No platform secret store configured for this target.");
+
+ public ValueTask RemoveAsync(string key, CancellationToken cancellationToken)
+ => throw new PlatformNotSupportedException("No platform secret store configured for this target.");
+ }
+}
diff --git a/JitHubV3/Services/GitHub/UnoTokenCacheGitHubTokenProvider.cs b/JitHubV3/Services/GitHub/UnoTokenCacheGitHubTokenProvider.cs
new file mode 100644
index 0000000..a90336e
--- /dev/null
+++ b/JitHubV3/Services/GitHub/UnoTokenCacheGitHubTokenProvider.cs
@@ -0,0 +1,57 @@
+using JitHub.GitHub.Abstractions.Security;
+using Uno.Extensions.Authentication;
+
+namespace JitHubV3.Services.GitHub;
+
+public sealed class UnoTokenCacheGitHubTokenProvider : IGitHubTokenProvider
+{
+ private readonly ITokenCache _tokenCache;
+ private readonly object _gate = new();
+ private string? _lastKnownToken;
+
+ public UnoTokenCacheGitHubTokenProvider(ITokenCache tokenCache)
+ {
+ _tokenCache = tokenCache;
+ }
+
+ public event EventHandler? TokenChanged;
+
+ public async ValueTask GetAccessTokenAsync(CancellationToken cancellationToken)
+ {
+ lock (_gate)
+ {
+ if (!string.IsNullOrWhiteSpace(_lastKnownToken))
+ {
+ return _lastKnownToken;
+ }
+ }
+
+ var tokens = await _tokenCache.GetAsync(cancellationToken).ConfigureAwait(false);
+ if (tokens is null || !tokens.TryGetValue("access_token", out var accessToken) || string.IsNullOrWhiteSpace(accessToken))
+ {
+ return null;
+ }
+
+ lock (_gate)
+ {
+ _lastKnownToken = accessToken;
+ return _lastKnownToken;
+ }
+ }
+
+ public void NotifyTokenChanged() => TokenChanged?.Invoke(this, EventArgs.Empty);
+
+ public void UpdateFromTokens(IDictionary? tokens)
+ {
+ var token = (tokens is not null && tokens.TryGetValue("access_token", out var accessToken) && !string.IsNullOrWhiteSpace(accessToken))
+ ? accessToken
+ : null;
+
+ lock (_gate)
+ {
+ _lastKnownToken = token;
+ }
+
+ NotifyTokenChanged();
+ }
+}
diff --git a/JitHubV3/appsettings.development.json b/JitHubV3/appsettings.development.json
index 417221f..bbfe2e0 100644
--- a/JitHubV3/appsettings.development.json
+++ b/JitHubV3/appsettings.development.json
@@ -4,7 +4,11 @@
},
"Logging": {
"LogLevel": {
- "Default": "Information"
+ "Default": "Information",
+ "JitHub.Data.Caching": "Debug",
+ "JitHub.GitHub.Octokit.Services": "Debug",
+ "JitHubV3.Presentation.IssuesViewModel": "Debug",
+ "JitHubV3.Presentation.IssueConversationViewModel": "Debug"
}
},
"GitHub": {
diff --git a/docs/github-service-layer-architecture.md b/docs/github-service-layer-architecture.md
new file mode 100644
index 0000000..2f485e9
--- /dev/null
+++ b/docs/github-service-layer-architecture.md
@@ -0,0 +1,463 @@
+# JitHub GitHub Service Layer + Data/Networking Architecture
+
+This document proposes an architecture for JitHub’s **GitHub service layer** and **networking/data layer**.
+
+It is designed so the UI can grow toward “native GitHub.com parity” (repos, issues, PRs, discussions, projects, activities, settings), while keeping the app code insulated from changes in GitHub API access strategies (Octokit.NET vs REST vs GraphQL vs generated SDKs).
+
+This doc is intentionally optimized for:
+
+- **Performance-first UX:** cached results show instantly, then refresh in the background.
+- **Isolation:** UI depends on *our* interfaces and models, not Octokit types.
+- **Correctness:** cache invalidation and polling are explicit and cancellable.
+- **Testability:** most logic is unit-testable without network.
+
+---
+
+## Scope and POC
+
+**POC pages (initial scope):**
+
+1) Main page: list current user’s repositories.
+2) Repo issues page: list issues for a selected repo (treat as “conversations” list).
+
+**Behavioral requirements:**
+
+- Cache every network call.
+- On navigation, **serve cached first** (if available), then **refresh** (revalidate) to get fresh data.
+- On “conversation-like” pages (issues/PRs/discussions), add **polling** with a configurable interval (default **10 seconds**) to refresh.
+- Networking/data work must not harm UI responsiveness.
+
+---
+
+## Existing repo context (what we build on)
+
+### OAuth is already server-mediated
+
+The repository already implements a server-mediated GitHub OAuth flow in the server project:
+
+- The server exchanges the OAuth code for a token.
+- The client obtains token information via an exchange endpoint.
+
+The service layer on the client should treat auth as a **token provider dependency**, not an implementation detail.
+
+### A prior cache prototype exists (JitHub_old)
+
+The older code contains an `InMemoryCache` concept that:
+
+- Subscribes to a URL key
+- Can poll with timers
+- Calls an invalidation callback
+
+We will keep the high-level idea (subscription + invalidation), but implement it in a more robust, testable, and cancellable way.
+
+---
+
+## High-level architecture
+
+### Layering
+
+The app should depend on GitHub through a thin, stable set of interfaces:
+
+- **UI layer (Uno / MVUX / MVVM)**
+ - ViewModels request data via feeds/observables and render it.
+ - Navigation determines which data to load.
+
+- **Application layer (use-case orchestration)**
+ - Coordinates pages’ flows like “load repos” / “load issues”.
+ - Owns refresh policy for pages (stale-while-revalidate + optional polling).
+
+- **Domain models (JitHub models)**
+ - DTOs designed for JitHub UI needs.
+ - No Octokit types exposed.
+
+- **GitHub service façade (our abstraction)**
+ - E.g. `IGitHubRepositoryService`, `IGitHubIssueService`, etc.
+ - Interfaces define *what* we need, not *how* to fetch it.
+
+- **Provider adapters (implementation)**
+ - Initially: `OctokitProvider` (Octokit.NET REST SDK).
+ - Future: `RestProvider` (raw HTTP), `GraphQLProvider`, or generated clients.
+
+- **Networking + cache runtime**
+ - A shared `HttpClient` setup (naming, headers, resilience).
+ - Cache store, invalidation, polling, and de-duplication.
+
+### Proposed projects (library split)
+
+Keep the GitHub client stack in separate library projects:
+
+- `JitHub.GitHub.Abstractions`
+ - Interfaces and JitHub models.
+- `JitHub.GitHub.Octokit`
+ - Octokit-based implementation.
+- `JitHub.Data.Caching`
+ - Cache primitives, invalidation streams, polling.
+- `JitHub.Networking`
+ - Http pipeline configuration and resilience.
+
+The UI project should reference only `JitHub.GitHub.Abstractions` (and the composition root references the implementation projects).
+
+---
+
+## Uno.Extensions grounding (how this fits Uno best practices)
+
+This architecture aligns with Uno.Extensions guidance:
+
+- **DI via hosting:** register services using `IHostBuilder.ConfigureServices(...)` and consume via constructor injection.
+- **Configuration:** load settings from embedded `appsettings.json` using `UseConfiguration()` and `EmbeddedSource()`.
+- **HTTP:** centralize endpoints/named clients through configuration so all services share consistent `HttpClient` behavior.
+- **MVUX feeds caching:** MVUX feed subscriptions have a concept of subscription caching (replay last value) within a viewmodel context.
+
+Even if we don’t use MVUX everywhere immediately, we should design our service layer so it can expose **reactive data sources** cleanly.
+
+---
+
+## Data access primitives
+
+### 1) Auth abstraction
+
+The GitHub provider needs tokens, but the UI shouldn’t care how they’re acquired.
+
+Define:
+
+- `IGitHubAuthSession`
+ - Has `AccessToken` (or provides a safe `GetTokenAsync`).
+ - Notifies when token changes (logout/login).
+
+- `IGitHubTokenProvider`
+ - `Task GetAccessTokenAsync(CancellationToken ct)`
+ - `IObservable` or an event for invalidation.
+
+**Token persistence is not optional.** Access tokens must be stored in a **secure, on-disk** location using the platform’s recommended secret store. Do not store tokens in the cache layer.
+
+#### Secure token storage (Uno guidance + platform best practice)
+
+Uno Platform documents cross-platform support for `Windows.Security.Credentials.PasswordVault`, intended for storing credentials and tokens. On supported platforms it uses hardware-backed encryption mechanisms:
+
+- **Windows:** stored via the platform credentials manager.
+- **Android:** backed by **AndroidKeyStore** (key material stays in the keystore; data is encrypted and persisted in the app directory).
+- **iOS:** stored in the **Keychain** (recommended iOS secret store).
+
+Important caveats from Uno’s documentation:
+
+- **Skia desktop (Linux / older Windows via Skia):** Uno does not support `PasswordVault` there.
+
+Therefore, the app should implement a small abstraction such as `ISecretStore` (get/set/delete) with platform-specific implementations:
+
+- Prefer `PasswordVault` on Windows/Android/iOS where available.
+- For Apple platforms when using Uno.Extensions that rely on Keychain (e.g., Uno.Extensions.Storage used by authentication scenarios), ensure the required Keychain entitlements are configured.
+- For Skia desktop targets: use the OS-native secret store (e.g., Keychain on macOS, Secret Service/libsecret on Linux) when we add support; until then, treat token persistence as a platform capability that must be implemented before enabling GitHub auth on that target.
+
+### 2) Request identity (cache key)
+
+Caching must be stable across providers. Don’t key purely by URL; key by a typed descriptor.
+
+Use a normalized cache key type:
+
+- `CacheKey`
+ - `string Operation` (e.g. `"repos.list"`, `"issues.list"`)
+ - `ImmutableDictionary` parameters (owner/repo/page/state)
+ - `string? UserScope` (so multi-account doesn’t cross-contaminate)
+
+This prevents accidental collisions and makes cache introspection/debugging possible.
+
+### 3) Cache entry + freshness
+
+Each cache entry should track:
+
+- `Value` (typed)
+- `FetchedAt` (UTC)
+- `ExpiresAt` (UTC) — optional TTL
+- `ETag` / `Last-Modified` — optional conditional request metadata
+- `LastError` — optional (for stale-but-usable)
+
+We want **stale-while-revalidate**:
+
+- Return cached value immediately.
+- Trigger a background refresh.
+- If refresh yields a different value, publish an update.
+
+### 4) Invalidation channel
+
+UI must be able to observe changes.
+
+This architecture intentionally does **not** standardize on a single reactive primitive. Different parts of the UI may choose different patterns:
+
+- **MVUX** where it fits (feeds/state patterns).
+- **MVVM** where needed, using `IObservable` (or events) for update streams.
+
+The service and cache layers should be shaped so they can be adapted into either model without rewriting networking/caching logic.
+
+The key requirement: **subscribers receive the cached value quickly** and then receive updates when refresh/polling completes.
+
+---
+
+## Threading + responsiveness
+
+### Key principle
+
+Avoid doing heavy work on the UI thread.
+
+- Network calls are naturally async; the danger is *continuations and callbacks*.
+- The cache layer must not assume a UI context.
+
+### Rules
+
+- Library/service code should use `ConfigureAwait(false)`.
+- Cache invalidation notifications should be raised on a background thread by default.
+- UI layer is responsible for marshaling to UI thread (or MVUX does this for you).
+
+### Worker execution model
+
+We still want a single, controlled execution pipeline to avoid bursty work:
+
+- A background **request coordinator** that:
+ - Deduplicates inflight requests per `CacheKey`.
+ - Limits concurrency (e.g., 4–8 concurrent outbound calls).
+ - Implements retry/backoff for transient failures.
+ - Observes cancellation and page lifetime.
+
+Implementation strategies:
+
+- Channel-based queue (`Channel` with a single consumer).
+- Semaphore-limited concurrency + inflight dictionary.
+
+---
+
+## Polling model (issues/PRs/discussions)
+
+### Requirement
+
+For “conversation-like” pages, refresh periodically (default 10 seconds).
+
+### Design
+
+Polling is a policy attached to a query/subscription:
+
+- Start polling when page becomes active.
+- Stop polling when navigating away or app is suspended.
+- Use a `CancellationToken` owned by the viewmodel/page scope.
+
+### Avoid thundering herd
+
+- Poll timers should align to a jittered schedule (e.g., 10s ± up to 1s).
+- Respect rate limits and back off if GitHub returns abuse/rate-limit headers.
+
+---
+
+## Provider adapters (start with Octokit.NET)
+
+### Why a provider layer
+
+We want to be able to swap:
+
+- Octokit REST
+- Raw REST (HttpClient)
+- GraphQL (experimental SDK / generated client)
+
+without rewriting UI.
+
+### Octokit adapter boundaries
+
+The Octokit implementation should be isolated behind internal mapping code:
+
+- Octokit models → JitHub models
+- Octokit pagination → JitHub paging
+- Octokit exceptions → normalized error types
+
+Do **not** leak Octokit types out of the provider assembly.
+
+### Networking concerns
+
+Octokit has its own HTTP pipeline, but we still want:
+
+- A consistent User-Agent
+- A consistent auth source (`IGitHubTokenProvider`)
+- Standard logging and correlation IDs
+
+If Octokit doesn’t allow full alignment with our `HttpClientFactory` configuration, keep that mismatch contained inside `JitHub.GitHub.Octokit`.
+
+---
+
+## Caching strategy details
+
+### Default policy
+
+- Cache every query.
+- Serve cached immediately if present.
+- Refresh on page navigation (revalidate).
+
+### Persistence (intentionally memory-only for now)
+
+Early in development, JitHub’s domain models and cache schemas will change frequently. Persisting cache entries to disk would quickly introduce invalid data and migration burden.
+
+For that reason:
+
+- The **data cache is in-memory only** (LRU + TTL/metadata).
+- Restarting the app may require refetching data.
+- When we later want disk caching, it should be introduced only after models stabilize and we have an explicit versioning/migration story.
+
+### Conditional requests (optional but high value)
+
+If we can store ETags / Last-Modified:
+
+- Send `If-None-Match` / `If-Modified-Since`.
+- Treat `304 Not Modified` as a successful refresh.
+
+This reduces bandwidth and helps with rate limits.
+
+### Inflight de-duplication
+
+If multiple consumers request the same `CacheKey`:
+
+- Share one network call.
+- Fan-out the results to all subscribers.
+
+### Memory constraints
+
+Define a maximum cache size:
+
+- LRU eviction (by key)
+- Optional per-entry size estimation
+
+For the POC, an in-memory LRU is enough.
+
+---
+
+## Error handling and UX expectations
+
+### Error normalization
+
+Introduce a small set of normalized errors:
+
+- Unauthorized (401) → prompt login.
+- Forbidden (403) with rate limit → show “rate limited” + retry after.
+- Network offline / timeout → show stale data + banner.
+
+### Stale-but-usable
+
+If cached value exists and refresh fails:
+
+- Keep showing cached.
+- Emit an update containing the error for UI to show non-blocking status.
+
+---
+
+## Telemetry and logging
+
+- Log per operation + duration + cache hit/miss.
+- Log rate limit headers when present.
+- Add correlation IDs per request chain (page navigation → queries).
+
+---
+
+## POC flows (repos → issues)
+
+### 1) Repos list
+
+- Query: `repos.list` (current user)
+- UI behavior:
+ - Show cached list immediately if available.
+ - Trigger refresh (revalidate).
+ - Update list when refresh completes.
+
+### 2) Issues list for repo
+
+- Query: `issues.list` with parameters:
+ - owner, repo
+ - filters (open/closed/all) (can default to open)
+ - paging (page size, page/cursor)
+
+- UI behavior:
+ - Show cached list immediately.
+ - Refresh immediately.
+ - Start polling every 10 seconds while page is active.
+ - For the POC UI, it is acceptable to fetch only the first page. Infinite scroll is a later UI task, but the service APIs must be paging-capable from day one.
+
+---
+
+## Recommended initial interface set (POC)
+
+Define narrow interfaces first; add breadth as we add features.
+
+To keep both MVUX and MVVM viable, expose **request/response** APIs at the abstraction layer, and add MVUX feed / observable adapters above them.
+
+- `IGitHubRepositoryService`
+ - `Task> GetMyRepositoriesAsync(RefreshMode refresh, CancellationToken ct)`
+
+- `IGitHubIssueService`
+ - `Task>> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, RefreshMode refresh, CancellationToken ct)`
+
+Supporting types:
+
+- `RepoKey` (Owner, Name)
+- `RepositorySummary` (Id, Name, OwnerLogin, IsPrivate, DefaultBranch, Description, UpdatedAt)
+- `IssueSummary` (Id, Number, Title, State, Author, CommentCount, UpdatedAt)
+
+Paging types (required even if the POC UI doesn’t implement infinite scrolling yet):
+
+- `PageRequest` (page size + either page number or cursor)
+- `PagedResult` (items + next page token/cursor + optional total/count hints)
+
+`RefreshMode` should support at least:
+
+- `PreferCacheThenRefresh` (stale-while-revalidate)
+- `ForceRefresh`
+- `CacheOnly` (useful for offline modes and startup rendering)
+
+Note: Whether to return `IFeed` vs `Task