diff --git a/Directory.Packages.props b/Directory.Packages.props index 0cdca7b..f6013d8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ --> + diff --git a/JitHub.Dashboard.Layouts.Tests/CardDeckLayoutMathTests.cs b/JitHub.Dashboard.Layouts.Tests/CardDeckLayoutMathTests.cs new file mode 100644 index 0000000..7f34799 --- /dev/null +++ b/JitHub.Dashboard.Layouts.Tests/CardDeckLayoutMathTests.cs @@ -0,0 +1,249 @@ +using System.Linq; +using FluentAssertions; +using JitHub.Dashboard.Layouts; + +namespace JitHub.Dashboard.Layouts.Tests; + +public sealed class CardDeckLayoutMathTests +{ + private static int ComputeDeckDepth(int itemIndex, int itemCount, int deckMaxVisibleCount) + { + var visualIndexFromFront = (itemCount - 1) - itemIndex; + return Math.Min(Math.Max(0, visualIndexFromFront), deckMaxVisibleCount - 1); + } + + [Test] + public void Auto_mode_uses_grid_at_or_above_threshold() + { + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Auto, + ModeWidthThreshold = 900, + CardMinWidth = 300, + CardHeight = 200, + Spacing = 16, + MaxColumns = 3, + }; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(900, 800), itemCount: 5, settings); + + items.Should().HaveCount(5); + items.Select(i => i.Transform).All(t => t.RotationDegrees == 0 && t.TranslateY == 0).Should().BeTrue(); + } + + [Test] + public void Auto_mode_uses_deck_below_threshold() + { + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Auto, + ModeWidthThreshold = 900, + CardMinWidth = 300, + CardHeight = 200, + DeckMaxVisibleCount = 4, + DeckOffsetY = 10, + DeckAngleStepDegrees = 2, + DeckScaleStep = 0, + }; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(899, 800), itemCount: 6, settings); + + items.Should().HaveCount(6); + + // IMPORTANT: last item is the “front/top” card. + items[5].Transform.TranslateY.Should().Be(0); + + // The deck has a small, stable per-item Y jitter. + // Validate the expected “10/20/30” spacing envelope with generous bounds. + items[4].Transform.TranslateY.Should().BeInRange(8.5, 11.5); + items[3].Transform.TranslateY.Should().BeInRange(17.0, 23.0); + items[2].Transform.TranslateY.Should().BeInRange(26.0, 34.0); + + // clamped (further back than max visible) – still around the deepest offset. + items[1].Transform.TranslateY.Should().BeInRange(26.0, 34.0); + items[0].Transform.TranslateY.Should().BeInRange(26.0, 34.0); + } + + [Test] + public void Grid_layout_centers_gutter_and_uses_fixed_card_size() + { + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Grid, + CardMinWidth = 200, + CardHeight = 100, + Spacing = 20, + MaxColumns = 3, + }; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(700, 800), itemCount: 4, settings); + + // 3 columns fit: row width = 3*200 + 2*20 = 640; gutter = (700-640)/2 = 30 + items[0].Rect.Should().Be(new LayoutRect(30, 0, 200, 100)); + items[1].Rect.Should().Be(new LayoutRect(250, 0, 200, 100)); + items[2].Rect.Should().Be(new LayoutRect(470, 0, 200, 100)); + items[3].Rect.Should().Be(new LayoutRect(30, 120, 200, 100)); + } + + [Test] + public void Grid_layout_respects_max_columns_and_never_overlaps() + { + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Grid, + CardMinWidth = 200, + CardHeight = 100, + Spacing = 16, + MaxColumns = 2, + }; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(2000, 800), itemCount: 6, settings); + items.Should().HaveCount(6); + + // With maxColumns=2, the 3rd item must start a new row. + items[0].Rect.Y.Should().Be(0); + items[1].Rect.Y.Should().Be(0); + items[2].Rect.Y.Should().Be(116); + + // Basic non-overlap check. + for (var i = 0; i < items.Count; i++) + { + items[i].Rect.IsFinite.Should().BeTrue(); + items[i].Rect.Width.Should().Be(200); + items[i].Rect.Height.Should().Be(100); + + for (var j = i + 1; j < items.Count; j++) + { + var a = items[i].Rect; + var b = items[j].Rect; + + var aRight = a.X + a.Width; + var bRight = b.X + b.Width; + var aBottom = a.Y + a.Height; + var bBottom = b.Y + b.Height; + + var overlapX = Math.Min(aRight, bRight) - Math.Max(a.X, b.X); + var overlapY = Math.Min(aBottom, bBottom) - Math.Max(a.Y, b.Y); + + (overlapX > 0 && overlapY > 0).Should().BeFalse(); + } + } + } + + [Test] + public void Deck_layout_has_stable_depth_envelope_and_z_ordering() + { + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Deck, + CardMinWidth = 320, + CardHeight = 180, + DeckMaxVisibleCount = 4, + DeckOffsetY = 10, + DeckAngleStepDegrees = 2, + DeckScaleStep = 0.02, + DeckBaseZ = 8, + DeckZStep = 6, + }; + + const int itemCount = 8; + var items = CardDeckLayoutMath.Compute(new LayoutSize(600, 800), itemCount, settings); + items.Should().HaveCount(itemCount); + + // All deck cards share the same base rect. + items.Select(i => i.Rect).Distinct().Should().HaveCount(1); + + var visibleDepth = Math.Min(itemCount, settings.DeckMaxVisibleCount); + + for (var i = 0; i < items.Count; i++) + { + var depth = ComputeDeckDepth(i, itemCount, settings.DeckMaxVisibleCount); + var transform = items[i].Transform; + + transform.IsFinite.Should().BeTrue(); + items[i].Z.Should().Be(settings.DeckBaseZ + (visibleDepth - 1 - depth) * settings.DeckZStep); + + // Depth 0 is the “front/top” card. + if (depth == 0) + { + transform.TranslateY.Should().Be(0); + transform.Scale.Should().Be(1); + transform.RotationDegrees.Should().Be(0); + } + else + { + // Expected translate Y is depth * offset with small jitter. + var expected = depth * settings.DeckOffsetY; + transform.TranslateY.Should().BeInRange(expected - 4, expected + 4); + + // Rotation should stay within the depth-based envelope. + Math.Abs(transform.RotationDegrees).Should().BeLessThanOrEqualTo(Math.Abs(settings.DeckAngleStepDegrees) * depth); + + // Scale tapers with depth, but should remain positive. + transform.Scale.Should().BeGreaterThan(0); + transform.Scale.Should().BeLessThan(1); + } + } + + // The last item is front/top and should have the highest Z. + items[^1].Z.Should().Be(items.Max(i => i.Z)); + } + + [Test] + public void Auto_mode_switching_is_stable_for_non_finite_thresholds() + { + // Non-finite thresholds sanitize to 0, which means any non-negative width selects Grid. + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Auto, + ModeWidthThreshold = double.NaN, + CardMinWidth = 320, + CardHeight = 180, + }; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(1, 1), itemCount: 2, settings); + items.Select(i => i.Transform.RotationDegrees).All(r => r == 0).Should().BeTrue(); + } + + [Test] + public void Layout_is_finite_for_common_inputs() + { + var settings = CardDeckLayoutSettings.Default; + var items = CardDeckLayoutMath.Compute(new LayoutSize(1024, 768), itemCount: 25, settings); + + items.Should().HaveCount(25); + items.All(i => i.Rect.IsFinite && i.Transform.IsFinite).Should().BeTrue(); + } + + [Test] + public void Layout_is_finite_for_pathological_inputs() + { + var settings = CardDeckLayoutSettings.Default with + { + Mode = CardDeckLayoutMode.Auto, + ModeWidthThreshold = double.PositiveInfinity, + CardMinWidth = double.NaN, + CardHeight = -10, + Spacing = double.NegativeInfinity, + DeckOffsetY = double.NaN, + DeckAngleStepDegrees = double.PositiveInfinity, + DeckScaleStep = double.NegativeInfinity, + DeckBaseZ = double.NaN, + DeckZStep = double.PositiveInfinity, + }; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(double.NaN, double.PositiveInfinity), itemCount: 50, settings); + items.Should().HaveCount(50); + items.All(i => i.Rect.IsFinite && i.Transform.IsFinite).Should().BeTrue(); + } + + [Test] + public void Layout_is_stable_for_large_item_counts() + { + var settings = CardDeckLayoutSettings.Default; + + var items = CardDeckLayoutMath.Compute(new LayoutSize(1280, 720), itemCount: 2000, settings); + items.Should().HaveCount(2000); + items.All(i => i.Rect.IsFinite && i.Transform.IsFinite).Should().BeTrue(); + } +} diff --git a/JitHub.Dashboard.Layouts.Tests/JitHub.Dashboard.Layouts.Tests.csproj b/JitHub.Dashboard.Layouts.Tests/JitHub.Dashboard.Layouts.Tests.csproj new file mode 100644 index 0000000..1baf4eb --- /dev/null +++ b/JitHub.Dashboard.Layouts.Tests/JitHub.Dashboard.Layouts.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + true + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.Dashboard.Layouts.Tests/LayoutProjectSmokeTests.cs b/JitHub.Dashboard.Layouts.Tests/LayoutProjectSmokeTests.cs new file mode 100644 index 0000000..bcd577e --- /dev/null +++ b/JitHub.Dashboard.Layouts.Tests/LayoutProjectSmokeTests.cs @@ -0,0 +1,12 @@ +using FluentAssertions; + +namespace JitHub.Dashboard.Layouts.Tests; + +public sealed class LayoutProjectSmokeTests +{ + [Test] + public void Project_wires_up() + { + typeof(JitHub.Dashboard.Layouts.AssemblyMarker).Should().NotBeNull(); + } +} diff --git a/JitHub.Dashboard.Layouts/AssemblyMarker.cs b/JitHub.Dashboard.Layouts/AssemblyMarker.cs new file mode 100644 index 0000000..24393d3 --- /dev/null +++ b/JitHub.Dashboard.Layouts/AssemblyMarker.cs @@ -0,0 +1,5 @@ +namespace JitHub.Dashboard.Layouts; + +public static class AssemblyMarker +{ +} diff --git a/JitHub.Dashboard.Layouts/CardDeckElevation.cs b/JitHub.Dashboard.Layouts/CardDeckElevation.cs new file mode 100644 index 0000000..d711cf8 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardDeckElevation.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using Microsoft.UI.Xaml; + +namespace JitHub.Dashboard.Layouts; + +public static class CardDeckElevation +{ + public static double GetBaseZ(DependencyObject obj) + => ReadDoubleOr(obj, BaseZProperty, fallback: double.NaN); + + public static void SetBaseZ(DependencyObject obj, double value) + => obj.SetValue(BaseZProperty, value); + + public static void ClearBaseZ(DependencyObject obj) + => obj.ClearValue(BaseZProperty); + + public static readonly DependencyProperty BaseZProperty = + DependencyProperty.RegisterAttached( + "BaseZ", + typeof(double), + typeof(CardDeckElevation), + new PropertyMetadata(double.NaN, OnZChanged)); + + public static double GetAnimationOffsetZ(DependencyObject obj) + => ReadDoubleOr(obj, AnimationOffsetZProperty, fallback: 0d); + + public static void SetAnimationOffsetZ(DependencyObject obj, double value) + => obj.SetValue(AnimationOffsetZProperty, value); + + public static void ClearAnimationOffsetZ(DependencyObject obj) + => obj.ClearValue(AnimationOffsetZProperty); + + public static readonly DependencyProperty AnimationOffsetZProperty = + DependencyProperty.RegisterAttached( + "AnimationOffsetZ", + typeof(double), + typeof(CardDeckElevation), + new PropertyMetadata(0d, OnZChanged)); + + private static void OnZChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not UIElement element) + { + return; + } + + var baseZ = ReadDoubleOr(element, BaseZProperty, fallback: double.NaN); + if (!double.IsFinite(baseZ)) + { + return; + } + + var offsetZ = ReadDoubleOr(element, AnimationOffsetZProperty, fallback: 0d); + if (!double.IsFinite(offsetZ)) + { + offsetZ = 0; + } + + var targetZ = baseZ + offsetZ; + if (!double.IsFinite(targetZ)) + { + targetZ = 0; + } + + var t = element.Translation; + element.Translation = new Vector3(t.X, t.Y, (float)targetZ); + } + + private static double ReadDoubleOr(DependencyObject obj, DependencyProperty property, double fallback) + { + try + { + return obj.GetValue(property) switch + { + double d when double.IsFinite(d) => d, + float f when float.IsFinite(f) => f, + int i => i, + _ => fallback, + }; + } + catch + { + return fallback; + } + } +} diff --git a/JitHub.Dashboard.Layouts/CardDeckLayout.cs b/JitHub.Dashboard.Layouts/CardDeckLayout.cs new file mode 100644 index 0000000..84ac842 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardDeckLayout.cs @@ -0,0 +1,329 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using System.Numerics; +using System.Runtime.CompilerServices; +using Windows.Foundation; + +namespace JitHub.Dashboard.Layouts; + +public sealed class CardDeckLayout : VirtualizingLayout +{ + private sealed class BaseZState + { + public BaseZState(float value) => Value = value; + public float Value { get; } + } + + private readonly ConditionalWeakTable _baseZBeforeDeck = new(); + + public CardDeckLayoutMode Mode + { + get => (CardDeckLayoutMode)GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public static readonly DependencyProperty ModeProperty = + DependencyProperty.Register( + nameof(Mode), + typeof(CardDeckLayoutMode), + typeof(CardDeckLayout), + new PropertyMetadata(CardDeckLayoutMode.Auto, OnLayoutPropertyChanged)); + + public double ModeWidthThreshold + { + get => (double)GetValue(ModeWidthThresholdProperty); + set => SetValue(ModeWidthThresholdProperty, value); + } + + public static readonly DependencyProperty ModeWidthThresholdProperty = + DependencyProperty.Register( + nameof(ModeWidthThreshold), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(900d, OnLayoutPropertyChanged)); + + public double CardMinWidth + { + get => (double)GetValue(CardMinWidthProperty); + set => SetValue(CardMinWidthProperty, value); + } + + public static readonly DependencyProperty CardMinWidthProperty = + DependencyProperty.Register( + nameof(CardMinWidth), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(320d, OnLayoutPropertyChanged)); + + public double CardHeight + { + get => (double)GetValue(CardHeightProperty); + set => SetValue(CardHeightProperty, value); + } + + public static readonly DependencyProperty CardHeightProperty = + DependencyProperty.Register( + nameof(CardHeight), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(180d, OnLayoutPropertyChanged)); + + public double Spacing + { + get => (double)GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + public static readonly DependencyProperty SpacingProperty = + DependencyProperty.Register( + nameof(Spacing), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(16d, OnLayoutPropertyChanged)); + + public int MaxColumns + { + get => (int)GetValue(MaxColumnsProperty); + set => SetValue(MaxColumnsProperty, value); + } + + public static readonly DependencyProperty MaxColumnsProperty = + DependencyProperty.Register( + nameof(MaxColumns), + typeof(int), + typeof(CardDeckLayout), + new PropertyMetadata(3, OnLayoutPropertyChanged)); + + public int DeckMaxVisibleCount + { + get => (int)GetValue(DeckMaxVisibleCountProperty); + set => SetValue(DeckMaxVisibleCountProperty, value); + } + + public static readonly DependencyProperty DeckMaxVisibleCountProperty = + DependencyProperty.Register( + nameof(DeckMaxVisibleCount), + typeof(int), + typeof(CardDeckLayout), + new PropertyMetadata(5, OnLayoutPropertyChanged)); + + public double DeckOffsetY + { + get => (double)GetValue(DeckOffsetYProperty); + set => SetValue(DeckOffsetYProperty, value); + } + + public static readonly DependencyProperty DeckOffsetYProperty = + DependencyProperty.Register( + nameof(DeckOffsetY), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(8d, OnLayoutPropertyChanged)); + + public double DeckAngleStepDegrees + { + get => (double)GetValue(DeckAngleStepDegreesProperty); + set => SetValue(DeckAngleStepDegreesProperty, value); + } + + public static readonly DependencyProperty DeckAngleStepDegreesProperty = + DependencyProperty.Register( + nameof(DeckAngleStepDegrees), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(1.25d, OnLayoutPropertyChanged)); + + public double DeckScaleStep + { + get => (double)GetValue(DeckScaleStepProperty); + set => SetValue(DeckScaleStepProperty, value); + } + + public static readonly DependencyProperty DeckScaleStepProperty = + DependencyProperty.Register( + nameof(DeckScaleStep), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(0.02d, OnLayoutPropertyChanged)); + + public double DeckBaseZ + { + get => (double)GetValue(DeckBaseZProperty); + set => SetValue(DeckBaseZProperty, value); + } + + public static readonly DependencyProperty DeckBaseZProperty = + DependencyProperty.Register( + nameof(DeckBaseZ), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(8d, OnLayoutPropertyChanged)); + + public double DeckZStep + { + get => (double)GetValue(DeckZStepProperty); + set => SetValue(DeckZStepProperty, value); + } + + public static readonly DependencyProperty DeckZStepProperty = + DependencyProperty.Register( + nameof(DeckZStep), + typeof(double), + typeof(CardDeckLayout), + new PropertyMetadata(6d, OnLayoutPropertyChanged)); + + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + var items = CardDeckLayoutMath.Compute( + new LayoutSize(availableSize.Width, availableSize.Height), + context.ItemCount, + GetSettings()); + + var desiredWidth = availableSize.Width; + var desiredHeight = 0d; + + var cardHeight = Math.Max(1, CardHeight); + var spacing = Math.Max(0, Spacing); + + if (items.Count > 0) + { + if (ResolveMode(availableSize.Width) == CardDeckLayoutMode.Grid) + { + var columns = Math.Max(1, Math.Min(MaxColumns, items.Count)); + var rows = (int)Math.Ceiling(items.Count / (double)columns); + desiredHeight = rows * cardHeight + Math.Max(0, rows - 1) * spacing; + } + else + { + var visibleDepth = Math.Min(items.Count, Math.Max(1, DeckMaxVisibleCount)); + desiredHeight = cardHeight + Math.Max(0, visibleDepth - 1) * Math.Max(0, DeckOffsetY); + } + } + + for (var i = 0; i < context.ItemCount; i++) + { + var element = context.GetOrCreateElementAt(i); + element.Measure(new Size(Math.Max(0, CardMinWidth), Math.Max(0, CardHeight))); + } + + return new Size(desiredWidth, desiredHeight); + } + + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + var mode = ResolveMode(finalSize.Width); + var items = CardDeckLayoutMath.Compute( + new LayoutSize(finalSize.Width, finalSize.Height), + context.ItemCount, + GetSettings()); + + for (var i = 0; i < items.Count; i++) + { + var element = context.GetOrCreateElementAt(i); + var item = items[i]; + element.Arrange(new Rect(item.Rect.X, item.Rect.Y, item.Rect.Width, item.Rect.Height)); + + ApplyTransform(element, item.Transform); + + if (mode == CardDeckLayoutMode.Deck && double.IsFinite(item.Z)) + { + if (!_baseZBeforeDeck.TryGetValue(element, out _)) + { + _baseZBeforeDeck.Add(element, new BaseZState(element.Translation.Z)); + } + + CardDeckElevation.SetBaseZ(element, item.Z); + } + else if (_baseZBeforeDeck.TryGetValue(element, out var baseState)) + { + var t = element.Translation; + element.Translation = new Vector3(t.X, t.Y, baseState.Value); + + CardDeckElevation.ClearBaseZ(element); + CardDeckElevation.SetAnimationOffsetZ(element, 0); + CardDeckElevation.ClearAnimationOffsetZ(element); + _baseZBeforeDeck.Remove(element); + } + } + + return finalSize; + } + + private CardDeckLayoutSettings GetSettings() => new( + Mode: Mode, + ModeWidthThreshold: ModeWidthThreshold, + CardMinWidth: CardMinWidth, + CardHeight: CardHeight, + Spacing: Spacing, + MaxColumns: MaxColumns, + DeckMaxVisibleCount: DeckMaxVisibleCount, + DeckOffsetY: DeckOffsetY, + DeckAngleStepDegrees: DeckAngleStepDegrees, + DeckScaleStep: DeckScaleStep, + DeckBaseZ: DeckBaseZ, + DeckZStep: DeckZStep); + + private CardDeckLayoutMode ResolveMode(double width) + { + if (Mode != CardDeckLayoutMode.Auto) + { + return Mode; + } + + return width >= ModeWidthThreshold ? CardDeckLayoutMode.Grid : CardDeckLayoutMode.Deck; + } + + private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CardDeckLayout layout) + { + layout.InvalidateMeasure(); + } + } + + private static void ApplyTransform(UIElement element, LayoutTransform transform) + { + if (element is null) + { + return; + } + + // Important for morph animations: + // - Layout needs to set its own transform (deck offsets/rotation/scale) + // - A presenter may want to apply an additive animation transform on top + // Keep them separate to avoid fighting over the same CompositeTransform. + var (layoutTransform, _) = EnsureTransforms(element); + + layoutTransform.TranslateX = transform.TranslateX; + layoutTransform.TranslateY = transform.TranslateY; + layoutTransform.Rotation = transform.RotationDegrees; + layoutTransform.ScaleX = transform.Scale; + layoutTransform.ScaleY = transform.Scale; + } + + internal static (CompositeTransform Layout, CompositeTransform Animation) EnsureTransforms(UIElement element) + { + if (element.RenderTransform is TransformGroup group + && group.Children.Count >= 2 + && group.Children[0] is CompositeTransform existingLayout + && group.Children[1] is CompositeTransform existingAnimation) + { + element.RenderTransformOrigin = new Windows.Foundation.Point(0.5, 0.5); + return (existingLayout, existingAnimation); + } + + // Migrate any existing CompositeTransform into the layout slot. + var layout = element.RenderTransform as CompositeTransform ?? new CompositeTransform(); + var animation = new CompositeTransform(); + + var newGroup = new TransformGroup(); + newGroup.Children.Add(layout); + newGroup.Children.Add(animation); + + element.RenderTransform = newGroup; + element.RenderTransformOrigin = new Windows.Foundation.Point(0.5, 0.5); + + return (layout, animation); + } +} diff --git a/JitHub.Dashboard.Layouts/CardDeckLayoutMath.cs b/JitHub.Dashboard.Layouts/CardDeckLayoutMath.cs new file mode 100644 index 0000000..25a2498 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardDeckLayoutMath.cs @@ -0,0 +1,288 @@ +namespace JitHub.Dashboard.Layouts; + +public static class CardDeckLayoutMath +{ + public static IReadOnlyList Compute(LayoutSize available, int itemCount, CardDeckLayoutSettings settings) + { + if (itemCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(itemCount)); + } + + if (itemCount == 0) + { + return Array.Empty(); + } + + var safeWidth = SanitizeSize(available.Width); + var safeHeight = SanitizeSize(available.Height); + + var cardMinWidth = Math.Max(1, SanitizeSize(settings.CardMinWidth)); + var cardHeight = Math.Max(1, SanitizeSize(settings.CardHeight)); + var spacing = Math.Max(0, SanitizeSize(settings.Spacing)); + + var mode = settings.Mode; + if (mode == CardDeckLayoutMode.Auto) + { + var threshold = Math.Max(0, SanitizeSize(settings.ModeWidthThreshold)); + mode = safeWidth >= threshold ? CardDeckLayoutMode.Grid : CardDeckLayoutMode.Deck; + } + + return mode switch + { + CardDeckLayoutMode.Grid => ComputeGrid( + width: safeWidth, + height: safeHeight, + itemCount: itemCount, + cardWidth: cardMinWidth, + cardHeight: cardHeight, + spacing: spacing, + maxColumns: Math.Max(1, settings.MaxColumns)), + + CardDeckLayoutMode.Deck => ComputeDeck( + width: safeWidth, + height: safeHeight, + itemCount: itemCount, + cardWidth: Math.Min(cardMinWidth, safeWidth <= 0 ? cardMinWidth : safeWidth), + cardHeight: cardHeight, + deckMaxVisible: Math.Max(1, settings.DeckMaxVisibleCount), + deckOffsetY: SanitizeSize(settings.DeckOffsetY), + deckAngleStepDegrees: SanitizeSize(settings.DeckAngleStepDegrees), + deckScaleStep: SanitizeSize(settings.DeckScaleStep), + deckBaseZ: SanitizeSize(settings.DeckBaseZ), + deckZStep: SanitizeSize(settings.DeckZStep)), + + _ => ComputeDeck( + width: safeWidth, + height: safeHeight, + itemCount: itemCount, + cardWidth: Math.Min(cardMinWidth, safeWidth <= 0 ? cardMinWidth : safeWidth), + cardHeight: cardHeight, + deckMaxVisible: Math.Max(1, settings.DeckMaxVisibleCount), + deckOffsetY: SanitizeSize(settings.DeckOffsetY), + deckAngleStepDegrees: SanitizeSize(settings.DeckAngleStepDegrees), + deckScaleStep: SanitizeSize(settings.DeckScaleStep), + deckBaseZ: SanitizeSize(settings.DeckBaseZ), + deckZStep: SanitizeSize(settings.DeckZStep)), + }; + } + + private static IReadOnlyList ComputeGrid( + double width, + double height, + int itemCount, + double cardWidth, + double cardHeight, + double spacing, + int maxColumns) + { + var columns = ComputeColumnCount(width, cardWidth, spacing, maxColumns); + var actualRowWidth = columns * cardWidth + (columns - 1) * spacing; + var gutterX = Math.Max(0, (width - actualRowWidth) / 2.0); + + var list = new CardDeckLayoutItem[itemCount]; + for (var i = 0; i < itemCount; i++) + { + var col = i % columns; + var row = i / columns; + + var x = gutterX + col * (cardWidth + spacing); + var y = row * (cardHeight + spacing); + + list[i] = new CardDeckLayoutItem( + Rect: new LayoutRect(x, y, cardWidth, cardHeight), + Transform: new LayoutTransform(0, 0, 0, 1), + Z: double.NaN); + } + + return list; + } + + private static IReadOnlyList ComputeDeck( + double width, + double height, + int itemCount, + double cardWidth, + double cardHeight, + int deckMaxVisible, + double deckOffsetY, + double deckAngleStepDegrees, + double deckScaleStep, + double deckBaseZ, + double deckZStep) + { + var x = Math.Max(0, (width - cardWidth) / 2.0); + var baseRect = new LayoutRect(x, 0, cardWidth, cardHeight); + + var visibleDepth = Math.Min(itemCount, deckMaxVisible); + + var list = new CardDeckLayoutItem[itemCount]; + for (var i = 0; i < itemCount; i++) + { + // IMPORTANT: ItemsRepeater renders later indices on top. + // Treat the last item as the “front/top” card (depth=0), so removing it + // causes the remaining cards to advance (depth decreases). + var visualIndexFromFront = (itemCount - 1) - i; + var depth = Math.Min(Math.Max(0, visualIndexFromFront), deckMaxVisible - 1); + + // The previous alternating pattern (0, -a, +2a, -3a, …) looks overly regular. + // Use a stable per-item scatter factor to get a more natural “deck” feel, + // while keeping the overall rotation envelope proportional to depth. + var rotation = ComputeDeckRotationDegrees(i, depth, deckAngleStepDegrees); + + var translateX = ComputeDeckTranslateX(width, cardWidth, i, depth, deckMaxVisible); + var translateY = (depth * deckOffsetY) + ComputeDeckTranslateYJitter(i, depth, deckMaxVisible, deckOffsetY); + + var scale = 1.0 - depth * deckScaleStep; + if (scale <= 0) + { + scale = 0.01; + } + + var z = deckBaseZ; + if (deckZStep > 0 && visibleDepth > 1) + { + // Depth 0 (top card) should be visually closest (largest Z). + // As cards move forward after a dismissal, their depth decreases, so Z increases. + z = deckBaseZ + (visibleDepth - 1 - depth) * deckZStep; + } + + list[i] = new CardDeckLayoutItem( + Rect: baseRect, + Transform: new LayoutTransform(translateX, translateY, rotation, scale), + Z: z); + } + + return list; + } + + private static double ComputeDeckTranslateX(double width, double cardWidth, int itemIndex, int depth, int deckMaxVisible) + { + if (depth <= 0 || deckMaxVisible <= 1) + { + return 0; + } + + var horizontalRoom = Math.Max(0, (width - cardWidth) / 2.0); + if (horizontalRoom <= 0) + { + return 0; + } + + // Keep it subtle and within the available gutter. + var maxOffsetX = Math.Min(horizontalRoom, Math.Min(16, cardWidth * 0.05)); + if (maxOffsetX <= 0) + { + return 0; + } + + var depthFactor = depth / (double)(deckMaxVisible - 1); + var scatter = HashToSignedUnit(itemIndex * 31 + 101); + return scatter * maxOffsetX * depthFactor; + } + + private static double ComputeDeckTranslateYJitter(int itemIndex, int depth, int deckMaxVisible, double deckOffsetY) + { + if (depth <= 0 || deckMaxVisible <= 1) + { + return 0; + } + + // Add a small, tasteful jitter so the stack feels less mechanically spaced. + var maxJitter = Math.Min(4, Math.Abs(deckOffsetY) * 0.35); + if (maxJitter <= 0) + { + return 0; + } + + var depthFactor = depth / (double)(deckMaxVisible - 1); + var scatter = HashToSignedUnit(itemIndex * 37 + 203); + return scatter * maxJitter * depthFactor; + } + + private static double ComputeDeckRotationDegrees(int itemIndex, int depth, double deckAngleStepDegrees) + { + if (depth <= 0 || deckAngleStepDegrees == 0) + { + return 0; + } + + // Base rotation grows with depth. + var maxAbsRotation = Math.Abs(deckAngleStepDegrees) * depth; + + // Scatter in [-1, +1]. Stable across runs. + var scatter = HashToSignedUnit(itemIndex + 17); + + // Avoid cards that end up almost perfectly aligned (looks “too tidy”). + // Keep the minimum visible tilt modest. + const double minScatterAbs = 0.25; + if (Math.Abs(scatter) < minScatterAbs) + { + scatter = scatter < 0 ? -minScatterAbs : minScatterAbs; + } + + // Keep it within the expected envelope. + return scatter * maxAbsRotation; + } + + private static double HashToSignedUnit(int value) + { + // A small, fast, deterministic integer hash → [0,1) → [-1,1). + unchecked + { + uint x = (uint)value; + x ^= x >> 16; + x *= 0x7feb352d; + x ^= x >> 15; + x *= 0x846ca68b; + x ^= x >> 16; + + var unit01 = (x & 0x00FFFFFF) / (double)0x01000000; // [0,1) + return (unit01 * 2.0) - 1.0; // [-1,1) + } + } + + private static int ComputeColumnCount(double width, double cardWidth, double spacing, int maxColumns) + { + if (width <= 0) + { + return 1; + } + + // How many fixed-width cards fit with spacing between them. + // columns*cardWidth + (columns-1)*spacing <= width + var denom = cardWidth + spacing; + if (denom <= 0) + { + return 1; + } + + var columns = (int)Math.Floor((width + spacing) / denom); + if (columns < 1) + { + columns = 1; + } + + if (columns > maxColumns) + { + columns = maxColumns; + } + + return columns; + } + + private static double SanitizeSize(double value) + { + if (!double.IsFinite(value)) + { + return 0; + } + + if (value < 0) + { + return 0; + } + + return value; + } +} diff --git a/JitHub.Dashboard.Layouts/CardDeckLayoutMode.cs b/JitHub.Dashboard.Layouts/CardDeckLayoutMode.cs new file mode 100644 index 0000000..1bd5b63 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardDeckLayoutMode.cs @@ -0,0 +1,8 @@ +namespace JitHub.Dashboard.Layouts; + +public enum CardDeckLayoutMode +{ + Auto = 0, + Grid = 1, + Deck = 2, +} diff --git a/JitHub.Dashboard.Layouts/CardDeckLayoutSettings.cs b/JitHub.Dashboard.Layouts/CardDeckLayoutSettings.cs new file mode 100644 index 0000000..4dfab28 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardDeckLayoutSettings.cs @@ -0,0 +1,30 @@ +namespace JitHub.Dashboard.Layouts; + +public sealed record CardDeckLayoutSettings( + CardDeckLayoutMode Mode, + double ModeWidthThreshold, + double CardMinWidth, + double CardHeight, + double Spacing, + int MaxColumns, + int DeckMaxVisibleCount, + double DeckOffsetY, + double DeckAngleStepDegrees, + double DeckScaleStep, + double DeckBaseZ, + double DeckZStep) +{ + public static CardDeckLayoutSettings Default => new( + Mode: CardDeckLayoutMode.Auto, + ModeWidthThreshold: 900, + CardMinWidth: 320, + CardHeight: 180, + Spacing: 16, + MaxColumns: 3, + DeckMaxVisibleCount: 5, + DeckOffsetY: 8, + DeckAngleStepDegrees: 1.25, + DeckScaleStep: 0.02, + DeckBaseZ: 8, + DeckZStep: 6); +} diff --git a/JitHub.Dashboard.Layouts/CardDeckPresenter.cs b/JitHub.Dashboard.Layouts/CardDeckPresenter.cs new file mode 100644 index 0000000..db64ec5 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardDeckPresenter.cs @@ -0,0 +1,1125 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; +using Windows.Foundation; +using System.Reflection; + +namespace JitHub.Dashboard.Layouts; + +/// +/// An that hosts and performs a smooth +/// morph animation when the effective mode switches (Grid ↔ Deck) on resize. +/// +/// Uses storyboarded animations against an additive +/// (cross-platform, Uno-friendly) so it does not fight the layout's own transforms. +/// +public sealed class CardDeckPresenter : ItemsRepeater +{ + public CardDeckPresenter() + { + _layout = new CardDeckLayout(); + Layout = _layout; + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + + ElementPrepared += OnElementPrepared; + ElementClearing += OnElementClearing; + LayoutUpdated += OnLayoutUpdated; + } + + private readonly CardDeckLayout _layout; + private readonly HashSet _realizedIndices = new(); + private readonly HashSet _swipingIndices = new(); + private readonly Dictionary _snapshots = new(); + private readonly Dictionary _lastPassSnapshots = new(); + private readonly Dictionary _snapshotsByCardId = new(); + private readonly Dictionary _lastPassSnapshotsByCardId = new(); + private readonly Dictionary _elementToIndex = new(); + private readonly Dictionary _cardIdToElement = new(); + private readonly Dictionary _elementToCardId = new(); + private readonly Dictionary _activeMorphStoryboards = new(); + private readonly Dictionary _activeSwipeStoryboards = new(); + private readonly Dictionary _activeZAnimations = new(); + + private bool _isFrameTrackingEnabled; + private DateTimeOffset _frameTrackingUntilUtc; + + private bool _isLoaded; + private bool _isUnloaded; + private LayoutSignature? _lastSignature; + private int _lastItemCount; + private bool _pendingMorph; + private bool _pendingMorphUsesCardId; + + private readonly record struct ZAnimationState(double FromOffsetZ, DateTimeOffset StartedUtc, int DurationMs); + + public Task SwipeAsync(long cardId, CardSwipeDirection direction) + => SwipeAsync(cardId, direction, CancellationToken.None); + + public async Task SwipeAsync(long cardId, CardSwipeDirection direction, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_isUnloaded || !_isLoaded) + { + return false; + } + + var index = TryFindIndexByCardId(cardId); + if (index is null) + { + return false; + } + + var element = TryGetElement(index.Value); + if (element is null) + { + return false; + } + + StopMorphStoryboard(element); + StopSwipeStoryboard(element, resetToNeutral: false); + + _swipingIndices.Add(index.Value); + + var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element); + + // Pivot around the center for a nicer "toss" feel. + animationTransform.CenterX = element.RenderSize.Width / 2; + animationTransform.CenterY = element.RenderSize.Height / 2; + + // Keep starting point as-is (supports swiping mid-morph). + var fromX = animationTransform.TranslateX; + var fromY = animationTransform.TranslateY; + var fromR = animationTransform.Rotation; + var fromOpacity = element.Opacity; + + var distanceX = Math.Max(ActualWidth, element.RenderSize.Width) + element.RenderSize.Width + 48; + var distanceY = Math.Max(ActualHeight, element.RenderSize.Height) + element.RenderSize.Height + 48; + + double toX = 0; + double toY = 0; + double toR; + + switch (direction) + { + case CardSwipeDirection.Left: + toX = -distanceX; + toR = -8; + break; + case CardSwipeDirection.Right: + toX = distanceX; + toR = 8; + break; + case CardSwipeDirection.Up: + toY = -distanceY; + toR = -6; + break; + case CardSwipeDirection.Down: + toY = distanceY; + toR = 6; + break; + default: + toR = 0; + break; + } + + const int durationMs = 280; + var duration = TimeSpan.FromMilliseconds(durationMs); + var moveEase = new CubicEase { EasingMode = EasingMode.EaseIn }; + var opacityEase = new QuadraticEase { EasingMode = EasingMode.EaseIn }; + + var storyboard = new Storyboard(); + + var xAnim = new DoubleAnimation { From = fromX, To = toX, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(xAnim, animationTransform); + Storyboard.SetTargetProperty(xAnim, nameof(CompositeTransform.TranslateX)); + storyboard.Children.Add(xAnim); + + var yAnim = new DoubleAnimation { From = fromY, To = toY, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(yAnim, animationTransform); + Storyboard.SetTargetProperty(yAnim, nameof(CompositeTransform.TranslateY)); + storyboard.Children.Add(yAnim); + + var rAnim = new DoubleAnimation { From = fromR, To = toR, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(rAnim, animationTransform); + Storyboard.SetTargetProperty(rAnim, nameof(CompositeTransform.Rotation)); + storyboard.Children.Add(rAnim); + + var oAnim = new DoubleAnimation { From = fromOpacity, To = 0, Duration = duration, EasingFunction = opacityEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(oAnim, element); + Storyboard.SetTargetProperty(oAnim, nameof(UIElement.Opacity)); + storyboard.Children.Add(oAnim); + + var tcs = new TaskCompletionSource(); + + _activeSwipeStoryboards[element] = storyboard; + storyboard.Completed += (_, _) => + { + if (_activeSwipeStoryboards.TryGetValue(element, out var current) && ReferenceEquals(current, storyboard)) + { + _activeSwipeStoryboards.Remove(element); + } + + _swipingIndices.Remove(index.Value); + element.IsHitTestVisible = false; + tcs.TrySetResult(true); + }; + + element.IsHitTestVisible = false; + storyboard.Begin(); + + using (cancellationToken.Register(() => + { + StopSwipeStoryboard(element, resetToNeutral: true); + _swipingIndices.Remove(index.Value); + tcs.TrySetCanceled(cancellationToken); + })) + { + return await tcs.Task; + } + } + + public CardDeckLayoutMode Mode + { + get => (CardDeckLayoutMode)GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public static readonly DependencyProperty ModeProperty = + DependencyProperty.Register( + nameof(Mode), + typeof(CardDeckLayoutMode), + typeof(CardDeckPresenter), + new PropertyMetadata(CardDeckLayoutMode.Auto, OnLayoutSettingsChanged)); + + public double ModeWidthThreshold + { + get => (double)GetValue(ModeWidthThresholdProperty); + set => SetValue(ModeWidthThresholdProperty, value); + } + + public static readonly DependencyProperty ModeWidthThresholdProperty = + DependencyProperty.Register( + nameof(ModeWidthThreshold), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(900d, OnLayoutSettingsChanged)); + + public double CardMinWidth + { + get => (double)GetValue(CardMinWidthProperty); + set => SetValue(CardMinWidthProperty, value); + } + + public static readonly DependencyProperty CardMinWidthProperty = + DependencyProperty.Register( + nameof(CardMinWidth), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(320d, OnLayoutSettingsChanged)); + + public double CardHeight + { + get => (double)GetValue(CardHeightProperty); + set => SetValue(CardHeightProperty, value); + } + + public static readonly DependencyProperty CardHeightProperty = + DependencyProperty.Register( + nameof(CardHeight), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(180d, OnLayoutSettingsChanged)); + + public double Spacing + { + get => (double)GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + public static readonly DependencyProperty SpacingProperty = + DependencyProperty.Register( + nameof(Spacing), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(16d, OnLayoutSettingsChanged)); + + public int MaxColumns + { + get => (int)GetValue(MaxColumnsProperty); + set => SetValue(MaxColumnsProperty, value); + } + + public static readonly DependencyProperty MaxColumnsProperty = + DependencyProperty.Register( + nameof(MaxColumns), + typeof(int), + typeof(CardDeckPresenter), + new PropertyMetadata(3, OnLayoutSettingsChanged)); + + public int DeckMaxVisibleCount + { + get => (int)GetValue(DeckMaxVisibleCountProperty); + set => SetValue(DeckMaxVisibleCountProperty, value); + } + + public static readonly DependencyProperty DeckMaxVisibleCountProperty = + DependencyProperty.Register( + nameof(DeckMaxVisibleCount), + typeof(int), + typeof(CardDeckPresenter), + new PropertyMetadata(5, OnLayoutSettingsChanged)); + + public double DeckOffsetY + { + get => (double)GetValue(DeckOffsetYProperty); + set => SetValue(DeckOffsetYProperty, value); + } + + public static readonly DependencyProperty DeckOffsetYProperty = + DependencyProperty.Register( + nameof(DeckOffsetY), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(8d, OnLayoutSettingsChanged)); + + public double DeckAngleStepDegrees + { + get => (double)GetValue(DeckAngleStepDegreesProperty); + set => SetValue(DeckAngleStepDegreesProperty, value); + } + + public static readonly DependencyProperty DeckAngleStepDegreesProperty = + DependencyProperty.Register( + nameof(DeckAngleStepDegrees), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(1.25d, OnLayoutSettingsChanged)); + + public double DeckScaleStep + { + get => (double)GetValue(DeckScaleStepProperty); + set => SetValue(DeckScaleStepProperty, value); + } + + public static readonly DependencyProperty DeckScaleStepProperty = + DependencyProperty.Register( + nameof(DeckScaleStep), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(0.02d, OnLayoutSettingsChanged)); + + public double DeckBaseZ + { + get => (double)GetValue(DeckBaseZProperty); + set => SetValue(DeckBaseZProperty, value); + } + + public static readonly DependencyProperty DeckBaseZProperty = + DependencyProperty.Register( + nameof(DeckBaseZ), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(8d, OnLayoutSettingsChanged)); + + public double DeckZStep + { + get => (double)GetValue(DeckZStepProperty); + set => SetValue(DeckZStepProperty, value); + } + + public static readonly DependencyProperty DeckZStepProperty = + DependencyProperty.Register( + nameof(DeckZStep), + typeof(double), + typeof(CardDeckPresenter), + new PropertyMetadata(6d, OnLayoutSettingsChanged)); + + public int MorphDurationMilliseconds + { + get => (int)GetValue(MorphDurationMillisecondsProperty); + set => SetValue(MorphDurationMillisecondsProperty, value); + } + + public static readonly DependencyProperty MorphDurationMillisecondsProperty = + DependencyProperty.Register( + nameof(MorphDurationMilliseconds), + typeof(int), + typeof(CardDeckPresenter), + new PropertyMetadata(420)); + + private static void OnLayoutSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CardDeckPresenter presenter) + { + presenter.ApplySettingsToLayout(); + presenter.InvalidateMeasure(); + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + _isLoaded = true; + _isUnloaded = false; + + ApplySettingsToLayout(); + _lastSignature = ComputeSignature(ActualWidth); + _lastItemCount = ItemsSourceView?.Count ?? 0; + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + _isUnloaded = true; + _isLoaded = false; + + StopFrameTracking(); + StopAllMorphStoryboards(); + StopAllSwipeStoryboards(); + + _pendingMorph = false; + _pendingMorphUsesCardId = false; + _snapshots.Clear(); + _lastPassSnapshots.Clear(); + _snapshotsByCardId.Clear(); + _lastPassSnapshotsByCardId.Clear(); + _realizedIndices.Clear(); + _swipingIndices.Clear(); + _elementToIndex.Clear(); + _cardIdToElement.Clear(); + _elementToCardId.Clear(); + } + + private void OnElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args) + { + var element = args.Element; + if (element is null) + { + return; + } + + _elementToIndex[element] = args.Index; + _realizedIndices.Add(args.Index); + + var data = (element as FrameworkElement)?.DataContext; + if (TryExtractCardId(data) is long id) + { + _cardIdToElement[id] = element; + _elementToCardId[element] = id; + } + + // If an element appears during a morph (virtualization), fade it in subtly. + if (_pendingMorph) + { + TryAnimateAppear(element); + } + } + + private void OnElementClearing(ItemsRepeater sender, ItemsRepeaterElementClearingEventArgs args) + { + StopMorphStoryboard(args.Element); + StopSwipeStoryboard(args.Element, resetToNeutral: false); + + if (_elementToCardId.TryGetValue(args.Element, out var cardId)) + { + _elementToCardId.Remove(args.Element); + if (_cardIdToElement.TryGetValue(cardId, out var current) && ReferenceEquals(current, args.Element)) + { + _cardIdToElement.Remove(cardId); + } + } + + if (_elementToIndex.TryGetValue(args.Element, out var index)) + { + _realizedIndices.Remove(index); + _elementToIndex.Remove(args.Element); + } + } + + private void OnLayoutUpdated(object? sender, object e) + { + if (_isUnloaded || !_isLoaded) + { + return; + } + + // Detect mode changes *after* layout has settled, using snapshots from the previous + // layout pass. This avoids capturing snapshots too late (e.g., SizeChanged fires + // after resize/layout, so deltas become ~0 and no animation is visible). + var signature = ComputeSignature(ActualWidth); + var itemCount = ItemsSourceView?.Count ?? 0; + if (_lastSignature is null) + { + _lastSignature = signature; + _lastItemCount = itemCount; + CaptureCurrentLayoutSnapshots(_lastPassSnapshots); + CaptureCurrentLayoutSnapshotsByCardId(_lastPassSnapshotsByCardId); + return; + } + + var countChanged = _lastItemCount != itemCount; + if (!_pendingMorph && (_lastSignature.Value != signature || countChanged)) + { + _lastSignature = signature; + _lastItemCount = itemCount; + CopySnapshots(_lastPassSnapshots, _snapshots); + CopySnapshots(_lastPassSnapshotsByCardId, _snapshotsByCardId); + _pendingMorphUsesCardId = countChanged && _snapshotsByCardId.Count > 0; + _pendingMorph = _pendingMorphUsesCardId ? _snapshotsByCardId.Count > 0 : _snapshots.Count > 0; + } + + if (_pendingMorph) + { + RunMorphAnimations(); + } + + CaptureCurrentLayoutSnapshots(_lastPassSnapshots); + CaptureCurrentLayoutSnapshotsByCardId(_lastPassSnapshotsByCardId); + } + + private void ApplySettingsToLayout() + { + _layout.Mode = Mode; + _layout.ModeWidthThreshold = ModeWidthThreshold; + _layout.CardMinWidth = CardMinWidth; + _layout.CardHeight = CardHeight; + _layout.Spacing = Spacing; + _layout.MaxColumns = MaxColumns; + _layout.DeckMaxVisibleCount = DeckMaxVisibleCount; + _layout.DeckOffsetY = DeckOffsetY; + _layout.DeckAngleStepDegrees = DeckAngleStepDegrees; + _layout.DeckScaleStep = DeckScaleStep; + _layout.DeckBaseZ = DeckBaseZ; + _layout.DeckZStep = DeckZStep; + } + + private CardDeckLayoutMode ResolveMode(double width) + { + if (Mode != CardDeckLayoutMode.Auto) + { + return Mode; + } + + return width >= ModeWidthThreshold ? CardDeckLayoutMode.Grid : CardDeckLayoutMode.Deck; + } + + private LayoutSignature ComputeSignature(double width) + { + var resolvedMode = ResolveMode(width); + if (resolvedMode == CardDeckLayoutMode.Grid) + { + return new LayoutSignature(resolvedMode, ComputeColumnCount(width)); + } + + return new LayoutSignature(resolvedMode, GridColumns: 0); + } + + private int ComputeColumnCount(double width) + { + if (width <= 0) + { + return 1; + } + + var cardWidth = Math.Max(1, CardMinWidth); + var spacing = Math.Max(0, Spacing); + var maxColumns = Math.Max(1, MaxColumns); + + var denom = cardWidth + spacing; + if (denom <= 0) + { + return 1; + } + + var columns = (int)Math.Floor((width + spacing) / denom); + if (columns < 1) + { + columns = 1; + } + + if (columns > maxColumns) + { + columns = maxColumns; + } + + return columns; + } + + private static void CopySnapshots(Dictionary source, Dictionary destination) + { + destination.Clear(); + foreach (var pair in source) + { + destination[pair.Key] = pair.Value; + } + } + + private static void CopySnapshots(Dictionary source, Dictionary destination) + { + destination.Clear(); + foreach (var pair in source) + { + destination[pair.Key] = pair.Value; + } + } + + private void CaptureCurrentLayoutSnapshots(Dictionary destination) + { + destination.Clear(); + + foreach (var index in _realizedIndices) + { + var element = TryGetElement(index); + if (element is null) + { + continue; + } + + var bounds = GetBoundsRelativeToPresenter(element); + var (rotation, scale) = GetLayoutTransform(element); + destination[index] = new Snapshot(bounds, rotation, scale, element.Translation.Z); + } + } + + private void CaptureCurrentLayoutSnapshotsByCardId(Dictionary destination) + { + destination.Clear(); + + foreach (var (cardId, element) in _cardIdToElement) + { + if (element is null) + { + continue; + } + + var bounds = GetBoundsRelativeToPresenter(element); + var (rotation, scale) = GetLayoutTransform(element); + destination[cardId] = new Snapshot(bounds, rotation, scale, element.Translation.Z); + } + } + + private void RunMorphAnimations() + { + if (!_pendingMorphUsesCardId && _snapshots.Count == 0) + { + _pendingMorph = false; + return; + } + + if (_pendingMorphUsesCardId && _snapshotsByCardId.Count == 0) + { + _pendingMorph = false; + _pendingMorphUsesCardId = false; + return; + } + + var durationMs = Math.Max(0, MorphDurationMilliseconds); + StartFrameTracking(durationMs); + + if (_pendingMorphUsesCardId) + { + foreach (var (cardId, snapshot) in _snapshotsByCardId) + { + if (!_cardIdToElement.TryGetValue(cardId, out var element) || element is null) + { + continue; + } + + if (_activeSwipeStoryboards.ContainsKey(element)) + { + continue; + } + + var newBounds = GetBoundsRelativeToPresenter(element); + var (newRotation, newScale) = GetLayoutTransform(element); + var newBaseZ = CardDeckElevation.GetBaseZ(element); + + var deltaX = snapshot.Bounds.X - newBounds.X; + var deltaY = snapshot.Bounds.Y - newBounds.Y; + + var deltaRotation = snapshot.RotationDegrees - newRotation; + + var scaleRatio = 1.0; + if (newScale is > 0 and < double.PositiveInfinity) + { + scaleRatio = snapshot.Scale / newScale; + } + + TryAnimateMorph(element, deltaX, deltaY, deltaRotation, scaleRatio, snapshot.Z, newBaseZ, durationMs); + } + + _snapshotsByCardId.Clear(); + } + else + { + foreach (var (index, snapshot) in _snapshots) + { + if (_swipingIndices.Contains(index)) + { + continue; + } + + var element = TryGetElement(index); + if (element is null) + { + continue; + } + + var newBounds = GetBoundsRelativeToPresenter(element); + var (newRotation, newScale) = GetLayoutTransform(element); + var newBaseZ = CardDeckElevation.GetBaseZ(element); + + var deltaX = snapshot.Bounds.X - newBounds.X; + var deltaY = snapshot.Bounds.Y - newBounds.Y; + + var deltaRotation = snapshot.RotationDegrees - newRotation; + + var scaleRatio = 1.0; + if (newScale is > 0 and < double.PositiveInfinity) + { + scaleRatio = snapshot.Scale / newScale; + } + + TryAnimateMorph(element, deltaX, deltaY, deltaRotation, scaleRatio, snapshot.Z, newBaseZ, durationMs); + } + + _snapshots.Clear(); + } + + _pendingMorph = false; + _pendingMorphUsesCardId = false; + } + + private void StartFrameTracking(int durationMs) + { + // During rapid resizing, the layout can change again while a morph is still in-flight. + // Keep refreshing "last pass" snapshots from the actual on-screen (animated) positions, + // so subsequent morphs start from the current state and remain continuous. + var now = DateTimeOffset.UtcNow; + var until = now + TimeSpan.FromMilliseconds(Math.Max(1, durationMs) + 120); + if (until > _frameTrackingUntilUtc) + { + _frameTrackingUntilUtc = until; + } + + if (_isFrameTrackingEnabled) + { + return; + } + + _isFrameTrackingEnabled = true; + CompositionTarget.Rendering += OnRendering; + } + + private void StopFrameTracking() + { + if (!_isFrameTrackingEnabled) + { + return; + } + + _isFrameTrackingEnabled = false; + CompositionTarget.Rendering -= OnRendering; + } + + private void OnRendering(object? sender, object e) + { + if (_isUnloaded || !_isLoaded) + { + StopFrameTracking(); + return; + } + + if (DateTimeOffset.UtcNow > _frameTrackingUntilUtc) + { + StopFrameTracking(); + return; + } + + TickZAnimations(); + + CaptureCurrentLayoutSnapshots(_lastPassSnapshots); + CaptureCurrentLayoutSnapshotsByCardId(_lastPassSnapshotsByCardId); + } + + private void TickZAnimations() + { + if (_activeZAnimations.Count == 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var elements = _activeZAnimations.Keys.ToArray(); + + foreach (var element in elements) + { + if (element is null) + { + _activeZAnimations.Remove(element!); + continue; + } + + if (!_activeZAnimations.TryGetValue(element, out var state)) + { + continue; + } + + var durationMs = Math.Max(1, state.DurationMs); + var t = (now - state.StartedUtc).TotalMilliseconds / durationMs; + if (!double.IsFinite(t)) + { + t = 1; + } + + if (t <= 0) + { + CardDeckElevation.SetAnimationOffsetZ(element, state.FromOffsetZ); + continue; + } + + if (t >= 1) + { + CardDeckElevation.SetAnimationOffsetZ(element, 0); + _activeZAnimations.Remove(element); + continue; + } + + // Cubic ease-out: 1 - (1 - t)^3 + var eased = 1 - Math.Pow(1 - t, 3); + var currentOffset = state.FromOffsetZ * (1 - eased); + CardDeckElevation.SetAnimationOffsetZ(element, currentOffset); + } + } + + private void TryAnimateMorph(UIElement element, double deltaX, double deltaY, double deltaRotation, double scaleRatio, double snapshotZ, double newBaseZ, int durationMs) + { + if (element is null) + { + return; + } + + // Skip tiny/no-op changes. + var shouldAnimateZ = double.IsFinite(newBaseZ) && Math.Abs(snapshotZ - newBaseZ) >= 0.5; + + if (Math.Abs(deltaX) < 0.5 + && Math.Abs(deltaY) < 0.5 + && Math.Abs(deltaRotation) < 0.2 + && (!double.IsFinite(scaleRatio) || Math.Abs(scaleRatio - 1.0) < 0.01) + && !shouldAnimateZ) + { + return; + } + + StopMorphStoryboard(element); + + // Ensure we have a dedicated animation CompositeTransform that the layout won't overwrite. + var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element); + + // Seed the additive transform to represent “old → new” delta, then animate back to neutral. + animationTransform.TranslateX = deltaX; + animationTransform.TranslateY = deltaY; + animationTransform.Rotation = deltaRotation; + + if (double.IsFinite(scaleRatio) && scaleRatio > 0) + { + animationTransform.ScaleX = scaleRatio; + animationTransform.ScaleY = scaleRatio; + } + else + { + animationTransform.ScaleX = 1; + animationTransform.ScaleY = 1; + } + + var duration = TimeSpan.FromMilliseconds(Math.Max(1, durationMs)); + var moveEase = new BackEase { Amplitude = 0.35, EasingMode = EasingMode.EaseOut }; + var rotateEase = new CubicEase { EasingMode = EasingMode.EaseOut }; + + var storyboard = new Storyboard(); + + var xAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(xAnim, animationTransform); + Storyboard.SetTargetProperty(xAnim, nameof(CompositeTransform.TranslateX)); + storyboard.Children.Add(xAnim); + + var yAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(yAnim, animationTransform); + Storyboard.SetTargetProperty(yAnim, nameof(CompositeTransform.TranslateY)); + storyboard.Children.Add(yAnim); + + var rAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = rotateEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(rAnim, animationTransform); + Storyboard.SetTargetProperty(rAnim, nameof(CompositeTransform.Rotation)); + storyboard.Children.Add(rAnim); + + var sxAnim = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(sxAnim, animationTransform); + Storyboard.SetTargetProperty(sxAnim, nameof(CompositeTransform.ScaleX)); + storyboard.Children.Add(sxAnim); + + var syAnim = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true }; + Storyboard.SetTarget(syAnim, animationTransform); + Storyboard.SetTargetProperty(syAnim, nameof(CompositeTransform.ScaleY)); + storyboard.Children.Add(syAnim); + + if (shouldAnimateZ) + { + // Layout has already applied the new base Z. Animate an additive offset back to 0. + var fromOffsetZ = snapshotZ - newBaseZ; + CardDeckElevation.SetAnimationOffsetZ(element, fromOffsetZ); + _activeZAnimations[element] = new ZAnimationState(fromOffsetZ, DateTimeOffset.UtcNow, Math.Max(1, durationMs)); + } + + _activeMorphStoryboards[element] = storyboard; + storyboard.Completed += (_, _) => OnMorphStoryboardCompleted(element, storyboard); + storyboard.Begin(); + } + + private void OnMorphStoryboardCompleted(UIElement element, Storyboard completed) + { + if (element is null) + { + return; + } + + // IMPORTANT (WinAppSDK): calling Stop() on completion can revert the animated + // properties back to their pre-animation values, which looks like a snap-back. + // Only detach tracking when the *current* storyboard completes. + if (_activeMorphStoryboards.TryGetValue(element, out var current) && ReferenceEquals(current, completed)) + { + _activeMorphStoryboards.Remove(element); + } + } + + private void StopMorphStoryboard(UIElement element) + { + if (element is null) + { + return; + } + + if (_activeMorphStoryboards.TryGetValue(element, out var active)) + { + _activeMorphStoryboards.Remove(element); + try + { + active.Stop(); + } + catch + { + // Ignore stop failures during teardown/resizing. + } + + // Ensure we don't leave a cancelled animation transform in a non-neutral state. + var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element); + animationTransform.TranslateX = 0; + animationTransform.TranslateY = 0; + animationTransform.Rotation = 0; + animationTransform.ScaleX = 1; + animationTransform.ScaleY = 1; + + CardDeckElevation.SetAnimationOffsetZ(element, 0); + _activeZAnimations.Remove(element); + } + } + + private void StopAllMorphStoryboards() + { + foreach (var element in _activeZAnimations.Keys) + { + if (element is not null) + { + CardDeckElevation.SetAnimationOffsetZ(element, 0); + } + } + + _activeZAnimations.Clear(); + + if (_activeMorphStoryboards.Count == 0) + { + return; + } + + foreach (var storyboard in _activeMorphStoryboards.Values) + { + try + { + storyboard.Stop(); + } + catch + { + // Ignore. + } + } + + _activeMorphStoryboards.Clear(); + } + + private void StopSwipeStoryboard(UIElement element, bool resetToNeutral) + { + if (element is null) + { + return; + } + + if (_activeSwipeStoryboards.TryGetValue(element, out var active)) + { + _activeSwipeStoryboards.Remove(element); + try + { + active.Stop(); + } + catch + { + // Ignore. + } + } + + if (resetToNeutral) + { + var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element); + animationTransform.TranslateX = 0; + animationTransform.TranslateY = 0; + animationTransform.Rotation = 0; + animationTransform.ScaleX = 1; + animationTransform.ScaleY = 1; + + element.Opacity = 1; + element.IsHitTestVisible = true; + } + } + + private void StopAllSwipeStoryboards() + { + if (_activeSwipeStoryboards.Count == 0) + { + return; + } + + foreach (var storyboard in _activeSwipeStoryboards.Values) + { + try + { + storyboard.Stop(); + } + catch + { + // Ignore. + } + } + + _activeSwipeStoryboards.Clear(); + } + + private int? TryFindIndexByCardId(long cardId) + { + var view = ItemsSourceView; + if (view is null) + { + return null; + } + + for (var i = 0; i < view.Count; i++) + { + var item = view.GetAt(i); + if (TryExtractCardId(item) is long id && id == cardId) + { + return i; + } + } + + return null; + } + + private static long? TryExtractCardId(object? item) + { + if (item is null) + { + return null; + } + + // Keep the presenter generic: treat "CardId" as a conventional key. + var type = item.GetType(); + var prop = type.GetProperty("CardId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (prop is null) + { + return null; + } + + try + { + var value = prop.GetValue(item); + return value switch + { + long l => l, + int i => i, + short s => s, + byte b => b, + string str when long.TryParse(str, out var parsed) => parsed, + _ => null, + }; + } + catch + { + return null; + } + } + + private void TryAnimateAppear(UIElement element) + { + if (element is null) + { + return; + } + + element.Opacity = 0; + var duration = TimeSpan.FromMilliseconds(160); + var ease = new CubicEase { EasingMode = EasingMode.EaseOut }; + + var storyboard = new Storyboard(); + var opacityAnim = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = ease }; + Storyboard.SetTarget(opacityAnim, element); + Storyboard.SetTargetProperty(opacityAnim, nameof(UIElement.Opacity)); + storyboard.Children.Add(opacityAnim); + storyboard.Begin(); + } + + private Rect GetBoundsRelativeToPresenter(UIElement element) + { + try + { + var transform = element.TransformToVisual(this); + return transform.TransformBounds(new Rect(0, 0, element.RenderSize.Width, element.RenderSize.Height)); + } + catch + { + return new Rect(0, 0, 0, 0); + } + } + + private static (double RotationDegrees, double Scale) GetLayoutTransform(UIElement element) + { + if (element.RenderTransform is TransformGroup group + && group.Children.Count >= 1 + && group.Children[0] is CompositeTransform layout) + { + // Layout uses uniform scale. + return (layout.Rotation, layout.ScaleX); + } + + if (element.RenderTransform is CompositeTransform ct) + { + // Layout uses uniform scale. + return (ct.Rotation, ct.ScaleX); + } + + return (0d, 1d); + } + + private readonly record struct LayoutSignature(CardDeckLayoutMode Mode, int GridColumns); + + private readonly record struct Snapshot(Rect Bounds, double RotationDegrees, double Scale, double Z); +} diff --git a/JitHub.Dashboard.Layouts/CardSwipeDirection.cs b/JitHub.Dashboard.Layouts/CardSwipeDirection.cs new file mode 100644 index 0000000..3f75977 --- /dev/null +++ b/JitHub.Dashboard.Layouts/CardSwipeDirection.cs @@ -0,0 +1,9 @@ +namespace JitHub.Dashboard.Layouts; + +public enum CardSwipeDirection +{ + Left, + Right, + Up, + Down, +} diff --git a/JitHub.Dashboard.Layouts/JitHub.Dashboard.Layouts.csproj b/JitHub.Dashboard.Layouts/JitHub.Dashboard.Layouts.csproj new file mode 100644 index 0000000..8fb29c7 --- /dev/null +++ b/JitHub.Dashboard.Layouts/JitHub.Dashboard.Layouts.csproj @@ -0,0 +1,10 @@ + + + + net10.0;net10.0-windows10.0.26100 + latest + enable + enable + + + diff --git a/JitHub.Dashboard.Layouts/LayoutPrimitives.cs b/JitHub.Dashboard.Layouts/LayoutPrimitives.cs new file mode 100644 index 0000000..334aa5d --- /dev/null +++ b/JitHub.Dashboard.Layouts/LayoutPrimitives.cs @@ -0,0 +1,27 @@ +namespace JitHub.Dashboard.Layouts; + +public readonly record struct LayoutSize(double Width, double Height); + +public readonly record struct LayoutPoint(double X, double Y); + +public readonly record struct LayoutRect(double X, double Y, double Width, double Height) +{ + public static LayoutRect FromSize(LayoutPoint origin, LayoutSize size) => new(origin.X, origin.Y, size.Width, size.Height); + + public bool IsFinite => + double.IsFinite(X) && + double.IsFinite(Y) && + double.IsFinite(Width) && + double.IsFinite(Height); +} + +public readonly record struct LayoutTransform(double TranslateX, double TranslateY, double RotationDegrees, double Scale) +{ + public bool IsFinite => + double.IsFinite(TranslateX) && + double.IsFinite(TranslateY) && + double.IsFinite(RotationDegrees) && + double.IsFinite(Scale); +} + +public sealed record CardDeckLayoutItem(LayoutRect Rect, LayoutTransform Transform, double Z); diff --git a/JitHub.Data.Caching/ObservableCollectionSync.cs b/JitHub.Data.Caching/ObservableCollectionSync.cs index b25d661..77c6a1d 100644 --- a/JitHub.Data.Caching/ObservableCollectionSync.cs +++ b/JitHub.Data.Caching/ObservableCollectionSync.cs @@ -32,9 +32,11 @@ public static void SyncById( if (source.Count == 0) { - if (target.Count != 0) + // Avoid Clear(), which raises a collection Reset and can cause UI flicker. + // Remove items individually so virtualization can animate/retain state. + while (target.Count > 0) { - target.Clear(); + target.RemoveAt(target.Count - 1); } return; diff --git a/JitHub.Data.Caching/StagedWorkScheduler.cs b/JitHub.Data.Caching/StagedWorkScheduler.cs new file mode 100644 index 0000000..3025e24 --- /dev/null +++ b/JitHub.Data.Caching/StagedWorkScheduler.cs @@ -0,0 +1,75 @@ +namespace JitHub.Data.Caching; + +public static class StagedWorkScheduler +{ + public static async Task RunAsync( + IReadOnlyList items, + Func tierSelector, + int maxTierInclusive, + int maxConcurrency, + Func workAsync, + CancellationToken ct) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + if (tierSelector is null) + { + throw new ArgumentNullException(nameof(tierSelector)); + } + + if (workAsync is null) + { + throw new ArgumentNullException(nameof(workAsync)); + } + + if (maxConcurrency <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxConcurrency)); + } + + var planned = items + .Select((item, index) => (item, index, tier: tierSelector(item))) + .Where(x => x.tier <= maxTierInclusive) + .OrderBy(x => x.tier) + .ThenBy(x => x.index) + .ToArray(); + + if (planned.Length == 0) + { + return; + } + + using var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); + var tasks = new List(capacity: planned.Length); + + foreach (var (item, _, _) in planned) + { + ct.ThrowIfCancellationRequested(); + + tasks.Add(RunOneAsync(item, semaphore, workAsync, ct)); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + private static async Task RunOneAsync( + TItem item, + SemaphoreSlim semaphore, + Func workAsync, + CancellationToken ct) + { + await semaphore.WaitAsync(ct).ConfigureAwait(false); + + try + { + await workAsync(item, ct).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } +} diff --git a/JitHub.GitHub.Abstractions/Models/ActivitySummary.cs b/JitHub.GitHub.Abstractions/Models/ActivitySummary.cs new file mode 100644 index 0000000..abad7ce --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/ActivitySummary.cs @@ -0,0 +1,9 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record ActivitySummary( + string Id, + RepoKey? Repo, + string Type, + string? ActorLogin, + string? Description, + DateTimeOffset CreatedAt); diff --git a/JitHub.GitHub.Abstractions/Models/NotificationSummary.cs b/JitHub.GitHub.Abstractions/Models/NotificationSummary.cs new file mode 100644 index 0000000..54dc492 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/NotificationSummary.cs @@ -0,0 +1,9 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record NotificationSummary( + string Id, + RepoKey Repo, + string Title, + string? Type, + DateTimeOffset? UpdatedAt, + bool Unread); diff --git a/JitHub.GitHub.Abstractions/Models/RepositorySnapshot.cs b/JitHub.GitHub.Abstractions/Models/RepositorySnapshot.cs new file mode 100644 index 0000000..beb5acc --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/RepositorySnapshot.cs @@ -0,0 +1,11 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record RepositorySnapshot( + RepoKey Repo, + bool IsPrivate, + string? DefaultBranch, + string? Description, + DateTimeOffset? UpdatedAt, + int StargazersCount, + int ForksCount, + int WatchersCount); diff --git a/JitHub.GitHub.Abstractions/Models/WorkItemSummary.cs b/JitHub.GitHub.Abstractions/Models/WorkItemSummary.cs new file mode 100644 index 0000000..0b1b1ad --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/WorkItemSummary.cs @@ -0,0 +1,12 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record WorkItemSummary( + long Id, + RepoKey Repo, + int Number, + string Title, + bool IsPullRequest, + string? State, + string? AuthorLogin, + int CommentCount, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs b/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs index fc4ef5c..170ffe0 100644 --- a/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs +++ b/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs @@ -2,4 +2,6 @@ namespace JitHub.GitHub.Abstractions.Queries; public sealed record IssueQuery( IssueStateFilter State, - string? SearchText = null); + string? SearchText = null, + IssueSortField? Sort = null, + IssueSortDirection? Direction = null); diff --git a/JitHub.GitHub.Abstractions/Queries/IssueSearchQuery.cs b/JitHub.GitHub.Abstractions/Queries/IssueSearchQuery.cs new file mode 100644 index 0000000..b6ab846 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Queries/IssueSearchQuery.cs @@ -0,0 +1,8 @@ +using JitHub.GitHub.Abstractions.Queries; + +namespace JitHub.GitHub.Abstractions.Queries; + +public sealed record IssueSearchQuery( + string Query, + IssueSortField? Sort = null, + IssueSortDirection? Direction = null); diff --git a/JitHub.GitHub.Abstractions/Queries/IssueSortDirection.cs b/JitHub.GitHub.Abstractions/Queries/IssueSortDirection.cs new file mode 100644 index 0000000..cbbbf47 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Queries/IssueSortDirection.cs @@ -0,0 +1,7 @@ +namespace JitHub.GitHub.Abstractions.Queries; + +public enum IssueSortDirection +{ + Asc = 0, + Desc = 1, +} diff --git a/JitHub.GitHub.Abstractions/Queries/IssueSortField.cs b/JitHub.GitHub.Abstractions/Queries/IssueSortField.cs new file mode 100644 index 0000000..293f1fc --- /dev/null +++ b/JitHub.GitHub.Abstractions/Queries/IssueSortField.cs @@ -0,0 +1,8 @@ +namespace JitHub.GitHub.Abstractions.Queries; + +public enum IssueSortField +{ + Created = 0, + Updated = 1, + Comments = 2, +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubActivityService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubActivityService.cs new file mode 100644 index 0000000..3ea49af --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubActivityService.cs @@ -0,0 +1,19 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubActivityService +{ + Task>> GetMyActivityAsync( + PageRequest page, + RefreshMode refresh, + CancellationToken ct); + + Task>> GetRepoActivityAsync( + RepoKey repo, + PageRequest page, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubIssueSearchService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubIssueSearchService.cs new file mode 100644 index 0000000..9ae4d4f --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubIssueSearchService.cs @@ -0,0 +1,15 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubIssueSearchService +{ + Task>> SearchAsync( + IssueSearchQuery query, + PageRequest page, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubNotificationPollingService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationPollingService.cs new file mode 100644 index 0000000..e6549fc --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationPollingService.cs @@ -0,0 +1,13 @@ +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Polling; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubNotificationPollingService +{ + Task StartNotificationsPollingAsync( + bool unreadOnly, + PageRequest page, + PollingRequest polling, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubNotificationService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationService.cs new file mode 100644 index 0000000..eb30ee4 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationService.cs @@ -0,0 +1,14 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubNotificationService +{ + Task>> GetMyNotificationsAsync( + bool unreadOnly, + PageRequest page, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryDetailsService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryDetailsService.cs new file mode 100644 index 0000000..85a2809 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryDetailsService.cs @@ -0,0 +1,12 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubRepositoryDetailsService +{ + Task GetRepositoryAsync( + RepoKey repo, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitActivityEventData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitActivityEventData.cs new file mode 100644 index 0000000..9d64ac3 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitActivityEventData.cs @@ -0,0 +1,11 @@ +using JitHub.GitHub.Abstractions.Models; + +namespace JitHub.GitHub.Octokit.Mapping; + +internal sealed record OctokitActivityEventData( + string? Id, + RepoKey? Repo, + string? Type, + string? ActorLogin, + string? Description, + DateTimeOffset CreatedAt); diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs b/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs index d78b406..8999830 100644 --- a/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs +++ b/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs @@ -72,6 +72,75 @@ public static IssueComment ToIssueComment(OctokitIssueCommentData comment) UpdatedAt: comment.UpdatedAt); } + internal static WorkItemSummary ToWorkItemSummary(OctokitWorkItemData item) + { + if (item is null) + { + throw new ArgumentNullException(nameof(item)); + } + + return new WorkItemSummary( + Id: item.Id, + Repo: item.Repo, + Number: item.Number, + Title: item.Title ?? string.Empty, + IsPullRequest: item.IsPullRequest, + State: item.State, + AuthorLogin: item.AuthorLogin, + CommentCount: item.CommentCount, + UpdatedAt: item.UpdatedAt); + } + + internal static NotificationSummary ToNotificationSummary(OctokitNotificationData notification) + { + if (notification is null) + { + throw new ArgumentNullException(nameof(notification)); + } + + return new NotificationSummary( + Id: notification.Id ?? string.Empty, + Repo: notification.Repo, + Title: notification.Title ?? string.Empty, + Type: notification.Type, + UpdatedAt: notification.UpdatedAt, + Unread: notification.Unread); + } + + internal static ActivitySummary ToActivitySummary(OctokitActivityEventData activity) + { + if (activity is null) + { + throw new ArgumentNullException(nameof(activity)); + } + + return new ActivitySummary( + Id: activity.Id ?? string.Empty, + Repo: activity.Repo, + Type: activity.Type ?? string.Empty, + ActorLogin: activity.ActorLogin, + Description: activity.Description, + CreatedAt: activity.CreatedAt); + } + + internal static RepositorySnapshot ToRepositorySnapshot(OctokitRepositoryDetailData repo) + { + if (repo is null) + { + throw new ArgumentNullException(nameof(repo)); + } + + return new RepositorySnapshot( + Repo: repo.Repo, + IsPrivate: repo.IsPrivate, + DefaultBranch: repo.DefaultBranch, + Description: repo.Description, + UpdatedAt: repo.UpdatedAt, + StargazersCount: repo.StargazersCount, + ForksCount: repo.ForksCount, + WatchersCount: repo.WatchersCount); + } + private static IssueState ParseIssueState(string? state) { if (string.Equals(state, "closed", StringComparison.OrdinalIgnoreCase)) diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitNotificationData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitNotificationData.cs new file mode 100644 index 0000000..ae244b7 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitNotificationData.cs @@ -0,0 +1,11 @@ +using JitHub.GitHub.Abstractions.Models; + +namespace JitHub.GitHub.Octokit.Mapping; + +internal sealed record OctokitNotificationData( + string Id, + RepoKey Repo, + string Title, + string? Type, + DateTimeOffset? UpdatedAt, + bool Unread); diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryDetailData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryDetailData.cs new file mode 100644 index 0000000..f0038a2 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryDetailData.cs @@ -0,0 +1,13 @@ +using JitHub.GitHub.Abstractions.Models; + +namespace JitHub.GitHub.Octokit.Mapping; + +internal sealed record OctokitRepositoryDetailData( + RepoKey Repo, + bool IsPrivate, + string? DefaultBranch, + string? Description, + DateTimeOffset? UpdatedAt, + int StargazersCount, + int ForksCount, + int WatchersCount); diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitWorkItemData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitWorkItemData.cs new file mode 100644 index 0000000..0bbc288 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitWorkItemData.cs @@ -0,0 +1,14 @@ +using JitHub.GitHub.Abstractions.Models; + +namespace JitHub.GitHub.Octokit.Mapping; + +internal sealed record OctokitWorkItemData( + long Id, + RepoKey Repo, + int Number, + string Title, + bool IsPullRequest, + string? State, + string? AuthorLogin, + int CommentCount, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs b/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs index 0a36f91..3585bb5 100644 --- a/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs +++ b/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs @@ -21,7 +21,12 @@ public static IServiceCollection AddJitHubGitHubServices(this IServiceCollection services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubActivityService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubActivityService.cs new file mode 100644 index 0000000..65dce05 --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubActivityService.cs @@ -0,0 +1,96 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubActivityService : IGitHubActivityService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + + public CachedGitHubActivityService(CacheRuntime cache, IGitHubDataSource dataSource) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public Task>> GetMyActivityAsync( + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + => GetAsync( + key: GitHubCacheKeys.MyActivity(page), + fetchAsync: token => FetchMyAsync(page, token), + page, + refresh, + ct); + + public Task>> GetRepoActivityAsync( + RepoKey repo, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + => GetAsync( + key: GitHubCacheKeys.RepoActivity(repo, page), + fetchAsync: token => FetchRepoAsync(repo, page, token), + page, + refresh, + ct); + + private async Task>> FetchMyAsync(PageRequest page, CancellationToken ct) + { + var data = await _dataSource.GetMyActivityAsync(page, ct).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToActivitySummary).ToArray(); + return new PagedResult>(items, Next: ComputeNext(page, items.Length)); + } + + private async Task>> FetchRepoAsync(RepoKey repo, PageRequest page, CancellationToken ct) + { + var data = await _dataSource.GetRepoActivityAsync(repo, page, ct).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToActivitySummary).ToArray(); + return new PagedResult>(items, Next: ComputeNext(page, items.Length)); + } + + private async Task>> GetAsync( + CacheKey key, + Func>>> fetchAsync, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + { + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached>>(key); + return cached.HasValue + ? cached.Value! + : new PagedResult>(Array.Empty(), Next: null); + } + + CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue + ? snapshot.Value! + : new PagedResult>(Array.Empty(), Next: null); + } + + private static PageRequest? ComputeNext(PageRequest page, int returnedCount) + { + if (page.Cursor is not null) + { + return null; + } + + if (page.PageNumber is not null && returnedCount == page.PageSize) + { + return PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize); + } + + return null; + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubIssueSearchService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueSearchService.cs new file mode 100644 index 0000000..376278f --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueSearchService.cs @@ -0,0 +1,58 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubIssueSearchService : IGitHubIssueSearchService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + + public CachedGitHubIssueSearchService(CacheRuntime cache, IGitHubDataSource dataSource) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task>> SearchAsync( + IssueSearchQuery query, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + { + var key = GitHubCacheKeys.SearchIssues(query, page); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached>>(key); + return cached.HasValue ? cached.Value! : new PagedResult>(Array.Empty(), Next: null); + } + + Func>>> fetchAsync = async token => + { + var data = await _dataSource.SearchIssuesAsync(query, page, token).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToWorkItemSummary).ToArray(); + + PageRequest? next = null; + if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize) + { + next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize); + } + + return new PagedResult>(items, next); + }; + + CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue + ? snapshot.Value! + : new PagedResult>(Array.Empty(), Next: null); + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationPollingService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationPollingService.cs new file mode 100644 index 0000000..6e1077e --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationPollingService.cs @@ -0,0 +1,56 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Polling; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; +using Microsoft.Extensions.Logging; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubNotificationPollingService : IGitHubNotificationPollingService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + private readonly ILogger _logger; + + public CachedGitHubNotificationPollingService( + CacheRuntime cache, + IGitHubDataSource dataSource, + ILogger logger) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartNotificationsPollingAsync(bool unreadOnly, PageRequest page, PollingRequest polling, CancellationToken ct) + { + _logger.LogInformation( + "Start notifications polling: unreadOnly={UnreadOnly} interval={IntervalMs}ms jitterMax={JitterMs}ms", + unreadOnly, + (long)polling.Interval.TotalMilliseconds, + (long)polling.JitterMax.GetValueOrDefault(TimeSpan.Zero).TotalMilliseconds); + + var key = GitHubCacheKeys.MyNotifications(unreadOnly, page); + var options = new PollingOptions(polling.Interval, polling.JitterMax); + + return _cache.StartPolling( + key, + options, + fetchAsync: async token => + { + var data = await _dataSource.GetMyNotificationsAsync(unreadOnly, page, token).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToNotificationSummary).ToArray(); + + PageRequest? next = null; + if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize) + { + next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize); + } + + return new PagedResult>(items, next); + }, + ct); + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationService.cs new file mode 100644 index 0000000..7d474e5 --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationService.cs @@ -0,0 +1,59 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubNotificationService : IGitHubNotificationService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + + public CachedGitHubNotificationService(CacheRuntime cache, IGitHubDataSource dataSource) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task>> GetMyNotificationsAsync( + bool unreadOnly, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + { + var key = GitHubCacheKeys.MyNotifications(unreadOnly, page); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached>>(key); + return cached.HasValue + ? cached.Value! + : new PagedResult>(Array.Empty(), Next: null); + } + + Func>>> fetchAsync = async token => + { + var data = await _dataSource.GetMyNotificationsAsync(unreadOnly, page, token).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToNotificationSummary).ToArray(); + + PageRequest? next = null; + if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize) + { + next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize); + } + + return new PagedResult>(items, next); + }; + + CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue + ? snapshot.Value! + : new PagedResult>(Array.Empty(), Next: null); + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryDetailsService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryDetailsService.cs new file mode 100644 index 0000000..6a20403 --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryDetailsService.cs @@ -0,0 +1,42 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubRepositoryDetailsService : IGitHubRepositoryDetailsService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + + public CachedGitHubRepositoryDetailsService(CacheRuntime cache, IGitHubDataSource dataSource) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task GetRepositoryAsync(RepoKey repo, RefreshMode refresh, CancellationToken ct) + { + var key = GitHubCacheKeys.Repository(repo); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached(key); + return cached.HasValue ? cached.Value : null; + } + + Func> fetchAsync = async token => + { + var data = await _dataSource.GetRepositoryAsync(repo, token).ConfigureAwait(false); + return data is null ? null : OctokitMappings.ToRepositorySnapshot(data); + }; + + var snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue ? snapshot.Value : null; + } +} diff --git a/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs b/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs index 91999d7..abb7a4a 100644 --- a/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs +++ b/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs @@ -10,10 +10,61 @@ internal static class GitHubCacheKeys public static CacheKey MyRepositories() => CacheKey.Create("github.repos.mine"); + public static CacheKey Repository(RepoKey repo) + => CacheKey.Create( + "github.repo.get", + userScope: null, + ("owner", repo.Owner), + ("repo", repo.Name)); + + public static CacheKey MyActivity(PageRequest page) + { + var pageNumber = page.PageNumber?.ToString() ?? string.Empty; + var cursor = page.Cursor ?? string.Empty; + + return CacheKey.Create( + "github.activity.mine", + userScope: null, + ("pageSize", page.PageSize.ToString()), + ("pageNumber", pageNumber), + ("cursor", cursor)); + } + + public static CacheKey RepoActivity(RepoKey repo, PageRequest page) + { + var pageNumber = page.PageNumber?.ToString() ?? string.Empty; + var cursor = page.Cursor ?? string.Empty; + + return CacheKey.Create( + "github.activity.repo", + userScope: null, + ("owner", repo.Owner), + ("repo", repo.Name), + ("pageSize", page.PageSize.ToString()), + ("pageNumber", pageNumber), + ("cursor", cursor)); + } + + public static CacheKey MyNotifications(bool unreadOnly, PageRequest page) + { + var pageNumber = page.PageNumber?.ToString() ?? string.Empty; + var cursor = page.Cursor ?? string.Empty; + + return CacheKey.Create( + "github.notifications.mine", + userScope: null, + ("unreadOnly", unreadOnly ? "1" : "0"), + ("pageSize", page.PageSize.ToString()), + ("pageNumber", pageNumber), + ("cursor", cursor)); + } + public static CacheKey Issues(RepoKey repo, IssueQuery query, PageRequest page) { var state = query.State.ToString(); var search = string.IsNullOrWhiteSpace(query.SearchText) ? string.Empty : query.SearchText.Trim(); + var sort = query.Sort?.ToString() ?? string.Empty; + var direction = query.Direction?.ToString() ?? string.Empty; var pageNumber = page.PageNumber?.ToString() ?? string.Empty; var cursor = page.Cursor ?? string.Empty; @@ -25,6 +76,8 @@ public static CacheKey Issues(RepoKey repo, IssueQuery query, PageRequest page) ("repo", repo.Name), ("state", state), ("search", search), + ("sort", sort), + ("direction", direction), ("pageSize", page.PageSize.ToString()), ("pageNumber", pageNumber), ("cursor", cursor)); @@ -53,4 +106,24 @@ public static CacheKey IssueComments(RepoKey repo, int issueNumber, PageRequest ("pageNumber", pageNumber), ("cursor", cursor)); } + + public static CacheKey SearchIssues(IssueSearchQuery query, PageRequest page) + { + var q = string.IsNullOrWhiteSpace(query.Query) ? string.Empty : query.Query.Trim(); + var sort = query.Sort?.ToString() ?? string.Empty; + var direction = query.Direction?.ToString() ?? string.Empty; + + var pageNumber = page.PageNumber?.ToString() ?? string.Empty; + var cursor = page.Cursor ?? string.Empty; + + return CacheKey.Create( + "github.issues.search", + userScope: null, + ("q", q), + ("sort", sort), + ("direction", direction), + ("pageSize", page.PageSize.ToString()), + ("pageNumber", pageNumber), + ("cursor", cursor)); + } } diff --git a/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs b/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs index fdd98fe..b43c8dc 100644 --- a/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs +++ b/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs @@ -9,8 +9,18 @@ internal interface IGitHubDataSource { Task> GetMyRepositoriesAsync(CancellationToken ct); + Task GetRepositoryAsync(RepoKey repo, CancellationToken ct); + + Task> GetMyActivityAsync(PageRequest page, CancellationToken ct); + + Task> GetRepoActivityAsync(RepoKey repo, PageRequest page, CancellationToken ct); + + Task> GetMyNotificationsAsync(bool unreadOnly, PageRequest page, CancellationToken ct); + Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct); + Task> SearchIssuesAsync(IssueSearchQuery query, PageRequest page, CancellationToken ct); + Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct); Task> GetIssueCommentsAsync(RepoKey repo, int issueNumber, PageRequest page, CancellationToken ct); diff --git a/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs b/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs index 572e3a3..5057488 100644 --- a/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs +++ b/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs @@ -39,6 +39,100 @@ public async Task> GetMyRepositoriesAsync(C .ToArray(); } + public async Task GetRepositoryAsync(RepoKey repo, CancellationToken ct) + { + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var r = await client.Repository.Get(repo.Owner, repo.Name).ConfigureAwait(false); + + return new OctokitRepositoryDetailData( + Repo: repo, + IsPrivate: r.Private, + DefaultBranch: r.DefaultBranch, + Description: r.Description, + UpdatedAt: r.UpdatedAt, + StargazersCount: r.StargazersCount, + ForksCount: r.ForksCount, + WatchersCount: r.SubscribersCount); + } + + public async Task> GetMyActivityAsync(PageRequest page, CancellationToken ct) + { + if (page.Cursor is not null) + { + throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider."); + } + + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var apiOptions = new ApiOptions + { + PageCount = 1, + PageSize = page.PageSize, + StartPage = page.PageNumber.GetValueOrDefault(1), + }; + + // Events API needs a username. + var me = await client.User.Current().ConfigureAwait(false); + var login = me.Login; + + var eventsClient = client.Activity.Events; + var raw = await InvokeToObjectListAsync(eventsClient, "GetAllUserPerformed", login, apiOptions).ConfigureAwait(false); + + return raw.Select(MapActivityEvent).ToArray(); + } + + public async Task> GetRepoActivityAsync(RepoKey repo, PageRequest page, CancellationToken ct) + { + if (page.Cursor is not null) + { + throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider."); + } + + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var apiOptions = new ApiOptions + { + PageCount = 1, + PageSize = page.PageSize, + StartPage = page.PageNumber.GetValueOrDefault(1), + }; + + var eventsClient = client.Activity.Events; + var raw = await InvokeToObjectListAsync(eventsClient, "GetAllForRepository", repo.Owner, repo.Name, apiOptions).ConfigureAwait(false); + + return raw.Select(MapActivityEvent).ToArray(); + } + + public async Task> GetMyNotificationsAsync(bool unreadOnly, PageRequest page, CancellationToken ct) + { + if (page.Cursor is not null) + { + throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider."); + } + + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var apiOptions = new ApiOptions + { + PageCount = 1, + PageSize = page.PageSize, + StartPage = page.PageNumber.GetValueOrDefault(1), + }; + + var notifications = await GetNotificationsCompatAsync(client, unreadOnly, apiOptions).ConfigureAwait(false); + + return notifications + .Select(n => new OctokitNotificationData( + Id: TryGetNotificationId(n), + Repo: new RepoKey(n.Repository?.Owner?.Login ?? string.Empty, n.Repository?.Name ?? string.Empty), + Title: n.Subject?.Title ?? string.Empty, + Type: n.Subject?.Type, + UpdatedAt: TryGetUpdatedAt(n), + Unread: n.Unread)) + .ToArray(); + } + public async Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct) { if (page.Cursor is not null) @@ -55,7 +149,7 @@ public async Task> GetIssuesAsync(RepoKey repo, StartPage = page.PageNumber.GetValueOrDefault(1), }; - if (!string.IsNullOrWhiteSpace(query.SearchText)) + if (!string.IsNullOrWhiteSpace(query.SearchText) || query.Sort is not null || query.Direction is not null) { var q = BuildSearchQuery(repo, query); var request = new SearchIssuesRequest(q) @@ -64,6 +158,8 @@ public async Task> GetIssuesAsync(RepoKey repo, Page = page.PageNumber.GetValueOrDefault(1), }; + ApplySearchSorting(request, query); + var result = await client.Search.SearchIssues(request).ConfigureAwait(false); return result.Items @@ -103,6 +199,349 @@ public async Task> GetIssuesAsync(RepoKey repo, .ToArray(); } + private static async Task> GetNotificationsCompatAsync(IGitHubClient client, bool unreadOnly, ApiOptions apiOptions) + { + // Octokit surface differs across versions. Prefer typed request if available; otherwise fall back to reflection. + try + { + var request = new NotificationsRequest + { + All = !unreadOnly, + Participating = false, + }; + + var result = await client.Activity.Notifications.GetAllForCurrent(request, apiOptions).ConfigureAwait(false); + return result; + } + catch + { + // Fallback: try to invoke GetAllForCurrent(...) overloads via reflection. + var notificationsClient = client.Activity.Notifications; + var type = notificationsClient.GetType(); + + var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => string.Equals(m.Name, "GetAllForCurrent", StringComparison.Ordinal)) + .ToArray(); + + // Try (ApiOptions) + var mApiOnly = methods.FirstOrDefault(m => + { + var p = m.GetParameters(); + return p.Length == 1 && p[0].ParameterType == typeof(ApiOptions); + }); + + if (mApiOnly is not null) + { + var task = (Task)mApiOnly.Invoke(notificationsClient, new object?[] { apiOptions })!; + await task.ConfigureAwait(false); + return ExtractTaskResult>(task) ?? Array.Empty(); + } + + // Try (object request, ApiOptions) + var mReqApi = methods.FirstOrDefault(m => + { + var p = m.GetParameters(); + return p.Length == 2 && p[1].ParameterType == typeof(ApiOptions); + }); + + if (mReqApi is not null) + { + var requestType = mReqApi.GetParameters()[0].ParameterType; + var req = Activator.CreateInstance(requestType); + if (req is not null) + { + TrySetBoolProperty(req, "All", !unreadOnly); + TrySetBoolProperty(req, "Participating", false); + } + + var task = (Task)mReqApi.Invoke(notificationsClient, new[] { req, apiOptions })!; + await task.ConfigureAwait(false); + return ExtractTaskResult>(task) ?? Array.Empty(); + } + + // Last resort: no apiOptions. + var mNoArgs = methods.FirstOrDefault(m => m.GetParameters().Length == 0); + if (mNoArgs is not null) + { + var task = (Task)mNoArgs.Invoke(notificationsClient, Array.Empty())!; + await task.ConfigureAwait(false); + var result = ExtractTaskResult>(task) ?? Array.Empty(); + return unreadOnly ? result.Where(n => n.Unread).ToArray() : result; + } + + return Array.Empty(); + } + } + + private static void TrySetBoolProperty(object target, string propertyName, bool value) + { + try + { + var prop = target.GetType().GetProperty(propertyName); + if (prop is not null && prop.CanWrite && prop.PropertyType == typeof(bool)) + { + prop.SetValue(target, value); + } + } + catch + { + // ignore + } + } + + private static T? ExtractTaskResult(Task task) where T : class + { + try + { + var prop = task.GetType().GetProperty("Result"); + return prop?.GetValue(task) as T; + } + catch + { + return null; + } + } + + private static string TryGetNotificationId(Notification n) + { + try + { + var prop = n.GetType().GetProperty("Id"); + var value = prop?.GetValue(n); + return value?.ToString() ?? string.Empty; + } + catch + { + return string.Empty; + } + } + + private static DateTimeOffset? TryGetUpdatedAt(Notification n) + { + try + { + var prop = n.GetType().GetProperty("UpdatedAt") ?? n.GetType().GetProperty("LastReadAt"); + var value = prop?.GetValue(n); + return value as DateTimeOffset?; + } + catch + { + return null; + } + } + + private static async Task> InvokeToObjectListAsync(object target, string methodName, params object[] args) + { + try + { + var methods = target.GetType().GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(m => string.Equals(m.Name, methodName, StringComparison.Ordinal)) + .ToArray(); + + var match = methods.FirstOrDefault(m => ParametersMatch(m.GetParameters(), args)); + if (match is null) + { + return Array.Empty(); + } + + var invoked = match.Invoke(target, args); + if (invoked is not Task task) + { + return Array.Empty(); + } + + await task.ConfigureAwait(false); + + var result = ExtractTaskResult(task); + if (result is System.Collections.IEnumerable enumerable) + { + var list = new List(); + foreach (var item in enumerable) + { + if (item is not null) + { + list.Add(item); + } + } + return list; + } + + return Array.Empty(); + } + catch + { + return Array.Empty(); + } + } + + private static bool ParametersMatch(System.Reflection.ParameterInfo[] parameters, object[] args) + { + if (parameters.Length != args.Length) + { + return false; + } + + for (var i = 0; i < parameters.Length; i++) + { + var arg = args[i]; + if (arg is null) + { + return false; + } + + var pType = parameters[i].ParameterType; + if (!pType.IsInstanceOfType(arg)) + { + return false; + } + } + + return true; + } + + private static OctokitActivityEventData MapActivityEvent(object e) + { + var id = TryGetStringProperty(e, "Id"); + var type = TryGetStringProperty(e, "Type"); + var createdAt = TryGetDateTimeOffsetProperty(e, "CreatedAt") ?? DateTimeOffset.MinValue; + + var actorLogin = TryGetNestedStringProperty(e, "Actor", "Login") + ?? TryGetNestedStringProperty(e, "Actor", "Name"); + + var repoFullName = TryGetNestedStringProperty(e, "Repo", "Name") + ?? TryGetNestedStringProperty(e, "Repository", "FullName") + ?? TryGetNestedStringProperty(e, "Repository", "Name"); + + var repo = TryParseRepoKey(repoFullName); + + // Avoid exposing raw payloads; keep this field for future UI-friendly summaries. + var description = (string?)null; + + return new OctokitActivityEventData( + Id: id, + Repo: repo, + Type: type, + ActorLogin: actorLogin, + Description: description, + CreatedAt: createdAt); + } + + private static string? TryGetStringProperty(object target, string propertyName) + { + try + { + var prop = target.GetType().GetProperty(propertyName); + var value = prop?.GetValue(target); + return value?.ToString(); + } + catch + { + return null; + } + } + + private static DateTimeOffset? TryGetDateTimeOffsetProperty(object target, string propertyName) + { + try + { + var prop = target.GetType().GetProperty(propertyName); + var value = prop?.GetValue(target); + return value as DateTimeOffset?; + } + catch + { + return null; + } + } + + private static string? TryGetNestedStringProperty(object target, string objectPropertyName, string nestedPropertyName) + { + try + { + var objProp = target.GetType().GetProperty(objectPropertyName); + var obj = objProp?.GetValue(target); + if (obj is null) + { + return null; + } + + var nestedProp = obj.GetType().GetProperty(nestedPropertyName); + var value = nestedProp?.GetValue(obj); + return value?.ToString(); + } + catch + { + return null; + } + } + + private static RepoKey? TryParseRepoKey(string? fullName) + { + if (string.IsNullOrWhiteSpace(fullName)) + { + return null; + } + + var parts = fullName.Trim().Split('/'); + if (parts.Length != 2) + { + return null; + } + + try + { + return new RepoKey(parts[0], parts[1]); + } + catch + { + return null; + } + } + + public async Task> SearchIssuesAsync(IssueSearchQuery query, PageRequest page, CancellationToken ct) + { + if (page.Cursor is not null) + { + throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider."); + } + + if (string.IsNullOrWhiteSpace(query.Query)) + { + return Array.Empty(); + } + + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var request = new SearchIssuesRequest(query.Query.Trim()) + { + PerPage = page.PageSize, + Page = page.PageNumber.GetValueOrDefault(1), + }; + + ApplySearchSorting(request, query.Sort, query.Direction); + + var result = await client.Search.SearchIssues(request).ConfigureAwait(false); + + return result.Items + .Select(i => + { + var repo = TryParseRepoKeyFromSearchItem(i) ?? new RepoKey(string.Empty, string.Empty); + var isPullRequest = TryGetIsPullRequest(i); + + return new OctokitWorkItemData( + Id: i.Id, + Repo: repo, + Number: i.Number, + Title: i.Title, + IsPullRequest: isPullRequest, + State: i.State.ToString(), + AuthorLogin: i.User?.Login, + CommentCount: i.Comments, + UpdatedAt: i.UpdatedAt); + }) + .ToArray(); + } + public async Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct) { var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); @@ -172,4 +611,157 @@ private static string BuildSearchQuery(RepoKey repo, IssueQuery query) return string.Join(" ", terms); } + + private static void ApplySearchSorting(SearchIssuesRequest request, IssueQuery query) + { + ApplySearchSorting(request, query.Sort, query.Direction); + } + + private static void ApplySearchSorting(SearchIssuesRequest request, IssueSortField? sort, IssueSortDirection? direction) + { + if (sort is null && direction is null) + { + return; + } + + var requestType = request.GetType(); + + if (sort is not null) + { + var sortProperty = requestType.GetProperty("SortField") ?? requestType.GetProperty("Sort"); + if (sortProperty is not null && sortProperty.CanWrite) + { + var sortValueName = sort.Value switch + { + IssueSortField.Created => "Created", + IssueSortField.Updated => "Updated", + IssueSortField.Comments => "Comments", + _ => "Updated", + }; + + TrySetEnumValue(request, sortProperty, sortValueName); + } + } + + if (direction is not null) + { + var orderProperty = requestType.GetProperty("Order") ?? requestType.GetProperty("Direction"); + if (orderProperty is not null && orderProperty.CanWrite) + { + var orderValueName = direction.Value switch + { + IssueSortDirection.Asc => "Ascending", + IssueSortDirection.Desc => "Descending", + _ => "Descending", + }; + + if (!TrySetEnumValue(request, orderProperty, orderValueName)) + { + // Some Octokit versions use Asc/Desc. + var alt = direction.Value == IssueSortDirection.Asc ? "Asc" : "Desc"; + _ = TrySetEnumValue(request, orderProperty, alt); + } + } + } + } + + private static RepoKey? TryParseRepoKeyFromSearchItem(object item) + { + Uri? repoUri = TryGetUriProperty(item, "RepositoryUrl") + ?? TryGetUriProperty(item, "Repository") + ?? TryGetUriProperty(item, "Url") + ?? TryGetUriProperty(item, "HtmlUrl"); + + if (repoUri is null) + { + return null; + } + + // Prefer parsing from API-style URLs: /repos/{owner}/{name}/... + var segments = repoUri.AbsolutePath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + for (var i = 0; i < segments.Length - 2; i++) + { + if (string.Equals(segments[i], "repos", StringComparison.OrdinalIgnoreCase)) + { + return new RepoKey(segments[i + 1], segments[i + 2]); + } + } + + // Fallback for github.com/{owner}/{name}/... + if (segments.Length >= 2 && string.Equals(repoUri.Host, "github.com", StringComparison.OrdinalIgnoreCase)) + { + return new RepoKey(segments[0], segments[1]); + } + + return null; + } + + private static Uri? TryGetUriProperty(object item, string propertyName) + { + try + { + var prop = item.GetType().GetProperty(propertyName); + if (prop is null) + { + return null; + } + + var value = prop.GetValue(item); + if (value is Uri uri) + { + return uri; + } + + if (value is string s && Uri.TryCreate(s, UriKind.Absolute, out var parsed)) + { + return parsed; + } + } + catch + { + // ignore + } + + return null; + } + + private static bool TryGetIsPullRequest(object item) + { + try + { + var prop = item.GetType().GetProperty("PullRequest"); + if (prop is null) + { + return false; + } + + return prop.GetValue(item) is not null; + } + catch + { + return false; + } + } + + private static bool TrySetEnumValue(object target, System.Reflection.PropertyInfo property, string valueName) + { + try + { + var enumType = property.PropertyType; + if (!enumType.IsEnum) + { + return false; + } + + var parsed = Enum.Parse(enumType, valueName, ignoreCase: true); + property.SetValue(target, parsed); + return true; + } + catch + { + return false; + } + } } diff --git a/JitHub.GitHub.Tests/CachedServicesTests.cs b/JitHub.GitHub.Tests/CachedServicesTests.cs index 8c0d07f..084cd39 100644 --- a/JitHub.GitHub.Tests/CachedServicesTests.cs +++ b/JitHub.GitHub.Tests/CachedServicesTests.cs @@ -174,18 +174,177 @@ public async Task Polling_populates_same_cache_entry_as_issue_service() cached.Items.Should().ContainSingle(i => i.Number == 42); } + [Test] + public async Task Polling_populates_same_cache_entry_as_notification_service() + { + var events = new CacheEventBus(); + var cache = new CacheRuntime(new InMemoryCacheStore(), events); + + var dataSource = new FakeGitHubDataSource + { + NotificationsFactory = (_, _, _) => + Task.FromResult>( + [ + new OctokitNotificationData( + Id: "n1", + Repo: new RepoKey("octo", "hello"), + Title: "Build is failing", + Type: "Issue", + UpdatedAt: DateTimeOffset.UtcNow, + Unread: true), + ]) + }; + + var notificationService = new CachedGitHubNotificationService(cache, dataSource); + var pollingService = new CachedGitHubNotificationPollingService(cache, dataSource, NullLogger.Instance); + + var page = PageRequest.FirstPage(pageSize: 5); + + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var sub = events.Subscribe(e => + { + if (e.Kind == CacheEventKind.Updated && e.Key.Operation == "github.notifications.mine") + { + updated.TrySetResult(); + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + var pollingTask = pollingService.StartNotificationsPollingAsync( + unreadOnly: true, + page, + new PollingRequest(TimeSpan.FromMilliseconds(1)), + cts.Token); + + await updated.Task; + cts.Cancel(); + + // Polling may complete as canceled. + try { await pollingTask; } catch (OperationCanceledException) { } + + var cached = await notificationService.GetMyNotificationsAsync(unreadOnly: true, page, RefreshMode.CacheOnly, CancellationToken.None); + cached.Items.Should().ContainSingle(n => n.Id == "n1"); + } + + [Test] + public async Task ActivityService_cache_only_returns_cached_value() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + + var now = DateTimeOffset.UtcNow; + var dataSource = new FakeGitHubDataSource + { + MyActivityFactory = (page, _) => + Task.FromResult>( + [ + new OctokitActivityEventData("e1", new RepoKey("octo", "hello"), "PushEvent", "me", null, now), + ]) + }; + + var sut = new CachedGitHubActivityService(cache, dataSource); + + var page = PageRequest.FirstPage(pageSize: 5); + + var first = await sut.GetMyActivityAsync(page, RefreshMode.ForceRefresh, CancellationToken.None); + first.Items.Should().HaveCount(1); + dataSource.GetMyActivityCallCount.Should().Be(1); + + var cached = await sut.GetMyActivityAsync(page, RefreshMode.CacheOnly, CancellationToken.None); + cached.Items.Should().HaveCount(1); + dataSource.GetMyActivityCallCount.Should().Be(1); + } + + [Test] + public async Task ActivityService_cache_key_varies_by_repo() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + + var dataSource = new FakeGitHubDataSource + { + RepoActivityFactory = (repo, page, _) => + { + var id = $"{repo.Owner}/{repo.Name}:{page.PageNumber}"; + return Task.FromResult>( + [ + new OctokitActivityEventData(id, repo, "WatchEvent", "me", null, DateTimeOffset.UtcNow), + ]); + } + }; + + var sut = new CachedGitHubActivityService(cache, dataSource); + var page = PageRequest.FromPageNumber(1, pageSize: 5); + + var repo1 = new RepoKey("octo", "hello"); + var repo2 = new RepoKey("octo", "world"); + + var r1 = await sut.GetRepoActivityAsync(repo1, page, RefreshMode.ForceRefresh, CancellationToken.None); + var r2 = await sut.GetRepoActivityAsync(repo2, page, RefreshMode.ForceRefresh, CancellationToken.None); + + dataSource.GetRepoActivityCallCount.Should().Be(2); + r1.Items.Single().Id.Should().NotBe(r2.Items.Single().Id); + + _ = await sut.GetRepoActivityAsync(repo1, page, RefreshMode.CacheOnly, CancellationToken.None); + dataSource.GetRepoActivityCallCount.Should().Be(2); + } + + [Test] + public async Task RepoDetailsService_cache_only_returns_cached_value() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + + var now = DateTimeOffset.UtcNow; + var dataSource = new FakeGitHubDataSource + { + RepoDetailsFactory = (repo, _) => + Task.FromResult( + new OctokitRepositoryDetailData( + Repo: repo, + IsPrivate: false, + DefaultBranch: "main", + Description: "Demo", + UpdatedAt: now, + StargazersCount: 12, + ForksCount: 3, + WatchersCount: 4)) + }; + + var sut = new CachedGitHubRepositoryDetailsService(cache, dataSource); + var repo = new RepoKey("octo", "hello"); + + var first = await sut.GetRepositoryAsync(repo, RefreshMode.ForceRefresh, CancellationToken.None); + first.Should().NotBeNull(); + dataSource.GetRepoDetailsCallCount.Should().Be(1); + + var cached = await sut.GetRepositoryAsync(repo, RefreshMode.CacheOnly, CancellationToken.None); + cached.Should().NotBeNull(); + dataSource.GetRepoDetailsCallCount.Should().Be(1); + } + internal sealed class FakeGitHubDataSource : IGitHubDataSource { public IReadOnlyList Repositories { get; init; } = Array.Empty(); + public Func>>? MyActivityFactory { get; init; } + + public Func>>? RepoActivityFactory { get; init; } + public Func>>? IssuesFactory { get; init; } public Func>? IssueFactory { get; init; } public Func>>? IssueCommentsFactory { get; init; } + public Func>>? SearchIssuesFactory { get; init; } + + public Func>>? NotificationsFactory { get; init; } + + public Func>? RepoDetailsFactory { get; init; } + public int GetMyRepositoriesCallCount { get; private set; } + public int GetMyActivityCallCount { get; private set; } + public int GetRepoActivityCallCount { get; private set; } public int GetIssuesCallCount { get; private set; } + public int GetRepoDetailsCallCount { get; private set; } public Task> GetMyRepositoriesAsync(CancellationToken ct) { @@ -193,6 +352,30 @@ public Task> GetMyRepositoriesAsync(Cancell return Task.FromResult(Repositories); } + public Task> GetMyActivityAsync(PageRequest page, CancellationToken ct) + { + GetMyActivityCallCount++; + + if (MyActivityFactory is not null) + { + return MyActivityFactory(page, ct); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task> GetRepoActivityAsync(RepoKey repo, PageRequest page, CancellationToken ct) + { + GetRepoActivityCallCount++; + + if (RepoActivityFactory is not null) + { + return RepoActivityFactory(repo, page, ct); + } + + return Task.FromResult>(Array.Empty()); + } + public Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct) { GetIssuesCallCount++; @@ -205,6 +388,38 @@ public Task> GetIssuesAsync(RepoKey repo, IssueQ return Task.FromResult>(Array.Empty()); } + public Task> SearchIssuesAsync(IssueSearchQuery query, PageRequest page, CancellationToken ct) + { + if (SearchIssuesFactory is not null) + { + return SearchIssuesFactory(query, page, ct); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task> GetMyNotificationsAsync(bool unreadOnly, PageRequest page, CancellationToken ct) + { + if (NotificationsFactory is not null) + { + return NotificationsFactory(unreadOnly, page, ct); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task GetRepositoryAsync(RepoKey repo, CancellationToken ct) + { + GetRepoDetailsCallCount++; + + if (RepoDetailsFactory is not null) + { + return RepoDetailsFactory(repo, ct); + } + + return Task.FromResult(null); + } + public Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct) { if (IssueFactory is not null) diff --git a/JitHub.GitHub.Tests/GitHubIssueSearchCacheKeyTests.cs b/JitHub.GitHub.Tests/GitHubIssueSearchCacheKeyTests.cs new file mode 100644 index 0000000..7c9b357 --- /dev/null +++ b/JitHub.GitHub.Tests/GitHubIssueSearchCacheKeyTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Octokit.Services; + +namespace JitHub.GitHub.Tests; + +public sealed class GitHubIssueSearchCacheKeyTests +{ + [Test] + public void SearchIssuesKey_TrimsQuery_AndIncludesSortAndDirection() + { + var query = new IssueSearchQuery( + Query: " is:open assignee:@me ", + Sort: IssueSortField.Updated, + Direction: IssueSortDirection.Desc); + + var page = PageRequest.FromPageNumber(2, pageSize: 25); + + var key = GitHubCacheKeys.SearchIssues(query, page); + + key.Operation.Should().Be("github.issues.search"); + + key.Parameters.Should().Contain(p => p.Key == "q" && p.Value == "is:open assignee:@me"); + key.Parameters.Should().Contain(p => p.Key == "sort" && p.Value == "Updated"); + key.Parameters.Should().Contain(p => p.Key == "direction" && p.Value == "Desc"); + key.Parameters.Should().Contain(p => p.Key == "pageSize" && p.Value == "25"); + key.Parameters.Should().Contain(p => p.Key == "pageNumber" && p.Value == "2"); + } +} diff --git a/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs b/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs index be892ce..0183fcd 100644 --- a/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs +++ b/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs @@ -10,10 +10,19 @@ public sealed class ObservableCollectionSyncTests private sealed record Item(long Id, int Revision); [Test] - public void SyncById_clears_when_source_empty() + public void SyncById_removes_items_when_source_empty_without_reset() { var target = new ObservableCollection { new(1, 0) }; + var sawReset = false; + target.CollectionChanged += (_, e) => + { + if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Reset) + { + sawReset = true; + } + }; + ObservableCollectionSync.SyncById( target, Array.Empty(), @@ -21,6 +30,7 @@ public void SyncById_clears_when_source_empty() shouldReplace: (_, _) => true); target.Should().BeEmpty(); + sawReset.Should().BeFalse(); } [Test] diff --git a/JitHub.GitHub.Tests/StagedWorkSchedulerTests.cs b/JitHub.GitHub.Tests/StagedWorkSchedulerTests.cs new file mode 100644 index 0000000..2672fe2 --- /dev/null +++ b/JitHub.GitHub.Tests/StagedWorkSchedulerTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using FluentAssertions; +using JitHub.Data.Caching; + +namespace JitHub.GitHub.Tests; + +public sealed class StagedWorkSchedulerTests +{ + [Test] + public async Task RunAsync_FiltersAndOrdersByTierThenOriginalOrder_WhenConcurrencyIsOne() + { + var items = new[] + { + (id: 0, tier: 1), + (id: 1, tier: 0), + (id: 2, tier: 2), + (id: 3, tier: 1), + }; + + var executed = new List(); + + await StagedWorkScheduler.RunAsync( + items, + tierSelector: x => x.tier, + maxTierInclusive: 1, + maxConcurrency: 1, + workAsync: (x, ct) => + { + ct.ThrowIfCancellationRequested(); + executed.Add(x.id); + return Task.CompletedTask; + }, + ct: CancellationToken.None); + + executed.Should().Equal(1, 0, 3); + } + + [Test] + public async Task RunAsync_StopsWorkOnCancellation() + { + var items = new[] + { + (id: 0, tier: 0), + (id: 1, tier: 0), + (id: 2, tier: 0), + }; + + using var cts = new CancellationTokenSource(); + var executed = new ConcurrentQueue(); + + Func<(int id, int tier), CancellationToken, Task> work = async (x, ct) => + { + executed.Enqueue(x.id); + + // Cancel while holding the semaphore so queued work doesn't start. + cts.Cancel(); + + // Observe cancellation inside the work item. + await Task.Delay(TimeSpan.FromMilliseconds(50), ct); + }; + + var act = () => StagedWorkScheduler.RunAsync( + items, + tierSelector: x => x.tier, + maxTierInclusive: 0, + maxConcurrency: 1, + workAsync: work, + ct: cts.Token); + + await act.Should().ThrowAsync(); + + executed.Should().Equal(0); + } +} diff --git a/JitHubV3.Tests/DashboardCards/DashboardCardIdTests.cs b/JitHubV3.Tests/DashboardCards/DashboardCardIdTests.cs new file mode 100644 index 0000000..7d3a834 --- /dev/null +++ b/JitHubV3.Tests/DashboardCards/DashboardCardIdTests.cs @@ -0,0 +1,82 @@ +using FluentAssertions; +using JitHubV3.Presentation; + +namespace JitHubV3.Tests.DashboardCards; + +public sealed class DashboardCardIdTests +{ + private const long NotificationsItemBase = 30_000_003_000_000; + private const long MyRecentActivityItemBase = 30_000_004_000_000; + private const long RepoRecentActivityItemBase = 20_000_004_000_000; + private const long FeedModulo = 1_000_000; + + [Test] + public void FixedCardIds_AreUnique() + { + var fixedIds = new[] + { + DashboardCardId.RepoRecentlyUpdatedIssues, + DashboardCardId.RepoSnapshot, + DashboardCardId.RepoRecentActivityEmpty, + DashboardCardId.MyRecentActivityEmpty, + DashboardCardId.NotificationsEmpty, + }; + + fixedIds.Should().OnlyHaveUniqueItems(); + } + + [Test] + public void RepoSnapshot_And_RepoRecentlyUpdatedIssues_AreDistinct_RegressionGuard() + { + DashboardCardId.RepoSnapshot.Should().NotBe(DashboardCardId.RepoRecentlyUpdatedIssues); + } + + [Test] + public void NotificationsItem_IsStable_AndInExpectedRange() + { + var id1 = DashboardCardId.NotificationsItem("notification_123"); + var id2 = DashboardCardId.NotificationsItem("notification_123"); + + id1.Should().Be(id2); + id1.Should().NotBe(DashboardCardId.NotificationsEmpty); + id1.Should().BeGreaterThanOrEqualTo(NotificationsItemBase); + id1.Should().BeLessThan(NotificationsItemBase + FeedModulo); + } + + [Test] + public void MyRecentActivityItem_IsStable_AndInExpectedRange() + { + var id1 = DashboardCardId.MyRecentActivityItem("evt_123"); + var id2 = DashboardCardId.MyRecentActivityItem("evt_123"); + + id1.Should().Be(id2); + id1.Should().NotBe(DashboardCardId.MyRecentActivityEmpty); + id1.Should().BeGreaterThanOrEqualTo(MyRecentActivityItemBase); + id1.Should().BeLessThan(MyRecentActivityItemBase + FeedModulo); + } + + [Test] + public void RepoRecentActivityItem_IsStable_AndInExpectedRange() + { + var id1 = DashboardCardId.RepoRecentActivityItem("evt_123"); + var id2 = DashboardCardId.RepoRecentActivityItem("evt_123"); + + id1.Should().Be(id2); + id1.Should().NotBe(DashboardCardId.RepoRecentActivityEmpty); + id1.Should().BeGreaterThanOrEqualTo(RepoRecentActivityItemBase); + id1.Should().BeLessThan(RepoRecentActivityItemBase + FeedModulo); + } + + [Test] + public void FeedItem_ThrowsArgumentNull_WhenIdIsNull() + { + // Explicitly validate runtime null-guards (even though inputs are non-nullable). + var act1 = () => DashboardCardId.NotificationsItem(null!); + var act2 = () => DashboardCardId.MyRecentActivityItem(null!); + var act3 = () => DashboardCardId.RepoRecentActivityItem(null!); + + act1.Should().Throw(); + act2.Should().Throw(); + act3.Should().Throw(); + } +} diff --git a/JitHubV3.Tests/DashboardProviders/ActivityProvidersTests.cs b/JitHubV3.Tests/DashboardProviders/ActivityProvidersTests.cs new file mode 100644 index 0000000..cdab92b --- /dev/null +++ b/JitHubV3.Tests/DashboardProviders/ActivityProvidersTests.cs @@ -0,0 +1,150 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHubV3.Presentation; + +namespace JitHubV3.Tests.DashboardProviders; + +public sealed class ActivityProvidersTests +{ + [Test] + public async Task MyRecentActivity_ReturnsCard_EvenWhenEmpty() + { + var provider = new MyRecentActivityDashboardCardProvider(new FakeActivityService([])); + var ctx = new DashboardContext(); + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().HaveCount(1); + cards[0].CardId.Should().Be(30_000_004); + cards[0].Title.Should().Be("Recent activity"); + } + + [Test] + public async Task MyRecentActivity_ReturnsOneCardPerItem_WhenNotEmpty() + { + var items = new[] + { + new ActivitySummary( + Id: "evt_1", + Repo: new RepoKey("octo", "one"), + Type: "PushEvent", + ActorLogin: "alice", + Description: "pushed commits", + CreatedAt: new DateTimeOffset(2025, 1, 2, 3, 4, 5, TimeSpan.Zero)), + new ActivitySummary( + Id: "evt_2", + Repo: new RepoKey("octo", "two"), + Type: "IssuesEvent", + ActorLogin: "bob", + Description: null, + CreatedAt: new DateTimeOffset(2025, 1, 2, 3, 4, 6, TimeSpan.Zero)), + }; + + var provider = new MyRecentActivityDashboardCardProvider(new FakeActivityService(items)); + var ctx = new DashboardContext(); + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().HaveCount(2); + cards.Select(c => c.CardId).Should().OnlyHaveUniqueItems(); + cards.All(c => c.Kind == DashboardCardKind.MyRecentActivity).Should().BeTrue(); + } + + [Test] + public async Task RepoRecentActivity_ReturnsNoCards_WhenNoSelectedRepo() + { + var provider = new RepoRecentActivityDashboardCardProvider(new FakeActivityService([])); + var ctx = new DashboardContext(); + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().BeEmpty(); + } + + [Test] + public async Task RepoRecentActivity_ReturnsOneCardPerItem_WhenSelectedRepo() + { + var items = new[] + { + new ActivitySummary( + Id: "evt_3", + Repo: new RepoKey("octo", "repo"), + Type: "WatchEvent", + ActorLogin: "carol", + Description: "starred", + CreatedAt: new DateTimeOffset(2025, 1, 2, 3, 4, 7, TimeSpan.Zero)), + new ActivitySummary( + Id: "evt_4", + Repo: new RepoKey("octo", "repo"), + Type: "ForkEvent", + ActorLogin: "dave", + Description: null, + CreatedAt: new DateTimeOffset(2025, 1, 2, 3, 4, 8, TimeSpan.Zero)), + new ActivitySummary( + Id: "evt_5", + Repo: new RepoKey("octo", "repo"), + Type: "IssueCommentEvent", + ActorLogin: null, + Description: "commented", + CreatedAt: new DateTimeOffset(2025, 1, 2, 3, 4, 9, TimeSpan.Zero)), + }; + + var provider = new RepoRecentActivityDashboardCardProvider(new FakeActivityService(items)); + var ctx = new DashboardContext + { + SelectedRepo = new RepoKey("octo", "repo"), + }; + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().HaveCount(3); + cards.Select(c => c.CardId).Should().OnlyHaveUniqueItems(); + cards.All(c => c.Kind == DashboardCardKind.RepoRecentActivity).Should().BeTrue(); + } + + [Test] + public async Task Providers_HonorCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var provider1 = new MyRecentActivityDashboardCardProvider(new FakeActivityService([])); + var provider2 = new RepoRecentActivityDashboardCardProvider(new FakeActivityService([])); + var ctx = new DashboardContext(); + + Func act1 = () => provider1.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + Func act2 = () => provider2.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + + await act1.Should().ThrowAsync(); + await act2.Should().ThrowAsync(); + } + + private sealed class FakeActivityService : IGitHubActivityService + { + private readonly IReadOnlyList _items; + + public FakeActivityService(IReadOnlyList items) + { + _items = items; + } + + public Task>> GetMyActivityAsync(PageRequest page, RefreshMode refresh, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + IReadOnlyList pageItems = _items.Take(page.PageSize).ToArray(); + return Task.FromResult(new PagedResult>(pageItems, Next: null)); + } + + public Task>> GetRepoActivityAsync(RepoKey repo, PageRequest page, RefreshMode refresh, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + IReadOnlyList pageItems = _items.Take(page.PageSize).ToArray(); + return Task.FromResult(new PagedResult>(pageItems, Next: null)); + } + } +} diff --git a/JitHubV3.Tests/DashboardProviders/MyWorkProvidersTests.cs b/JitHubV3.Tests/DashboardProviders/MyWorkProvidersTests.cs new file mode 100644 index 0000000..d7dbc83 --- /dev/null +++ b/JitHubV3.Tests/DashboardProviders/MyWorkProvidersTests.cs @@ -0,0 +1,77 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHubV3.Presentation; + +namespace JitHubV3.Tests.DashboardProviders; + +public sealed class MyWorkProvidersTests +{ + [Test] + public async Task MyAssignedWork_ReturnsCard_EvenWhenEmpty() + { + var provider = new MyAssignedWorkDashboardCardProvider(new FakeSearchService([])); + var ctx = new DashboardContext(); + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().HaveCount(1); + cards[0].CardId.Should().Be(30_000_001); + cards[0].Title.Should().Be("My assigned issues"); + } + + [Test] + public async Task MyReviewRequests_ReturnsCard_EvenWhenEmpty() + { + var provider = new MyReviewRequestsDashboardCardProvider(new FakeSearchService([])); + var ctx = new DashboardContext(); + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().HaveCount(1); + cards[0].CardId.Should().Be(30_000_002); + cards[0].Title.Should().Be("Review requests"); + } + + [Test] + public async Task Providers_HonorCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var provider1 = new MyAssignedWorkDashboardCardProvider(new FakeSearchService([])); + var provider2 = new MyReviewRequestsDashboardCardProvider(new FakeSearchService([])); + var ctx = new DashboardContext(); + + Func act1 = () => provider1.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + Func act2 = () => provider2.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + + await act1.Should().ThrowAsync(); + await act2.Should().ThrowAsync(); + } + + private sealed class FakeSearchService : IGitHubIssueSearchService + { + private readonly IReadOnlyList _items; + + public FakeSearchService(IReadOnlyList items) + { + _items = items; + } + + public Task>> SearchAsync( + IssueSearchQuery query, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + IReadOnlyList pageItems = _items.Take(page.PageSize).ToArray(); + return Task.FromResult(new PagedResult>(pageItems, Next: null)); + } + } +} diff --git a/JitHubV3.Tests/DashboardProviders/RepoIssuesProvidersTests.cs b/JitHubV3.Tests/DashboardProviders/RepoIssuesProvidersTests.cs new file mode 100644 index 0000000..09f23e3 --- /dev/null +++ b/JitHubV3.Tests/DashboardProviders/RepoIssuesProvidersTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHubV3.Presentation; + +using IssueStateModel = JitHub.GitHub.Abstractions.Models.IssueState; + +namespace JitHubV3.Tests.DashboardProviders; + +public sealed class RepoIssuesProvidersTests +{ + [Test] + public async Task RepoIssuesSummary_ReturnsEmpty_WhenNoSelectedRepo() + { + var provider = new RepoIssuesSummaryDashboardCardProvider(new FakeIssuesService([])); + var ctx = new DashboardContext { SelectedRepo = null }; + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().BeEmpty(); + } + + [Test] + public async Task RepoRecentlyUpdated_ReturnsEmpty_WhenNoSelectedRepo() + { + var provider = new RepoRecentlyUpdatedIssuesDashboardCardProvider(new FakeIssuesService([])); + var ctx = new DashboardContext { SelectedRepo = null }; + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().BeEmpty(); + } + + [Test] + public async Task RepoIssuesSummary_ProducesStableCardIds_AndDeterministicOrdering() + { + var now = DateTimeOffset.Now; + var issues = new[] + { + new IssueSummary(1, 12, "B", IssueStateModel.Open, "a", 10, now.AddMinutes(-10)), + new IssueSummary(2, 11, "A", IssueStateModel.Open, "a", 2, now.AddMinutes(-5)), + new IssueSummary(3, 13, "C", IssueStateModel.Open, "a", 1, now.AddMinutes(-30)), + }; + + var provider = new RepoIssuesSummaryDashboardCardProvider(new FakeIssuesService(issues)); + var ctx = new DashboardContext { SelectedRepo = new RepoKey("octocat", "Hello-World") }; + + var cards1 = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + var cards2 = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards1.Select(c => c.CardId).Should().Equal(20_000_001, 20_000_002); + cards2.Select(c => c.CardId).Should().Equal(20_000_001, 20_000_002); + + cards1.Should().Equal(cards2); + } + + [Test] + public async Task RepoRecentlyUpdated_TakesTopFive_ByUpdatedAtDesc() + { + var baseTime = new DateTimeOffset(2025, 12, 29, 8, 0, 0, TimeSpan.Zero); + + var issues = Enumerable.Range(1, 8) + .Select(i => new IssueSummary( + Id: i, + Number: 100 + i, + Title: $"Issue {i}", + State: IssueStateModel.Open, + AuthorLogin: "me", + CommentCount: 0, + UpdatedAt: baseTime.AddMinutes(-i))) + // Reverse so input isn't already sorted. + .Reverse() + .ToArray(); + + var provider = new RepoRecentlyUpdatedIssuesDashboardCardProvider(new FakeIssuesService(issues)); + var ctx = new DashboardContext { SelectedRepo = new RepoKey("octocat", "Hello-World") }; + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().HaveCount(1); + cards[0].CardId.Should().Be(20_000_003); + + // Most recent should be #101 (i=1), then #102, ... + cards[0].Summary.Should().Contain("#101"); + cards[0].Summary.Should().Contain("#102"); + cards[0].Summary.Should().Contain("#105"); + cards[0].Summary.Should().NotContain("#106"); + cards[0].Summary.Should().NotContain("#108"); + } + + [Test] + public void Providers_HonorCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var provider1 = new RepoIssuesSummaryDashboardCardProvider(new FakeIssuesService([])); + var provider2 = new RepoRecentlyUpdatedIssuesDashboardCardProvider(new FakeIssuesService([])); + var ctx = new DashboardContext { SelectedRepo = new RepoKey("octocat", "Hello-World") }; + + Func act1 = () => provider1.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + Func act2 = () => provider2.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + + act1.Should().ThrowAsync(); + act2.Should().ThrowAsync(); + } + + private sealed class FakeIssuesService : IGitHubIssueService + { + private readonly IReadOnlyList _items; + + public FakeIssuesService(IReadOnlyList items) + { + _items = items; + } + + public Task>> GetIssuesAsync( + RepoKey repo, + IssueQuery query, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + IReadOnlyList pageItems = _items.Take(page.PageSize).ToArray(); + return Task.FromResult(new PagedResult>(pageItems, Next: null)); + } + } +} diff --git a/JitHubV3.Tests/DashboardProviders/RepoSnapshotProvidersTests.cs b/JitHubV3.Tests/DashboardProviders/RepoSnapshotProvidersTests.cs new file mode 100644 index 0000000..1404b3a --- /dev/null +++ b/JitHubV3.Tests/DashboardProviders/RepoSnapshotProvidersTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHubV3.Presentation; + +namespace JitHubV3.Tests.DashboardProviders; + +public sealed class RepoSnapshotProvidersTests +{ + [Test] + public async Task RepoSnapshot_ReturnsEmpty_WhenNoSelectedRepo() + { + var provider = new RepoSnapshotDashboardCardProvider(new FakeRepoDetailsService(null)); + var ctx = new DashboardContext { SelectedRepo = null }; + + var cards = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards.Should().BeEmpty(); + } + + [Test] + public async Task RepoSnapshot_ReturnsStableCardId_WhenSelectedRepoPresent() + { + var snapshot = new RepositorySnapshot( + Repo: new RepoKey("octocat", "Hello-World"), + IsPrivate: false, + DefaultBranch: "main", + Description: "Demo", + UpdatedAt: new DateTimeOffset(2025, 12, 30, 10, 0, 0, TimeSpan.Zero), + StargazersCount: 12, + ForksCount: 3, + WatchersCount: 4); + + var provider = new RepoSnapshotDashboardCardProvider(new FakeRepoDetailsService(snapshot)); + var ctx = new DashboardContext { SelectedRepo = new RepoKey("octocat", "Hello-World") }; + + var cards1 = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + var cards2 = await provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, CancellationToken.None); + + cards1.Should().HaveCount(1); + cards1[0].CardId.Should().Be(20_000_005); + cards1[0].Kind.Should().Be(DashboardCardKind.RepoSnapshot); + + cards1.Should().Equal(cards2); + } + + [Test] + public void Provider_HonorsCancellation() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var provider = new RepoSnapshotDashboardCardProvider(new FakeRepoDetailsService(null)); + var ctx = new DashboardContext { SelectedRepo = new RepoKey("octocat", "Hello-World") }; + + Func act = () => provider.GetCardsAsync(ctx, RefreshMode.CacheOnly, cts.Token); + act.Should().ThrowAsync(); + } + + private sealed class FakeRepoDetailsService : IGitHubRepositoryDetailsService + { + private readonly RepositorySnapshot? _snapshot; + + public FakeRepoDetailsService(RepositorySnapshot? snapshot) + { + _snapshot = snapshot; + } + + public Task GetRepositoryAsync(RepoKey repo, RefreshMode refresh, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_snapshot); + } + } +} diff --git a/JitHubV3.Tests/JitHubV3.Tests.csproj b/JitHubV3.Tests/JitHubV3.Tests.csproj index 2121a33..4155325 100644 --- a/JitHubV3.Tests/JitHubV3.Tests.csproj +++ b/JitHubV3.Tests/JitHubV3.Tests.csproj @@ -1,18 +1,14 @@ - net10.0-windows10.0.26100 - net10.0 + net10.0 false true - - x64 - - + @@ -24,5 +20,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHubV3.UITests/Given_DashboardPage.cs b/JitHubV3.UITests/Given_DashboardPage.cs new file mode 100644 index 0000000..6087f49 --- /dev/null +++ b/JitHubV3.UITests/Given_DashboardPage.cs @@ -0,0 +1,109 @@ +using FluentAssertions; + +namespace JitHubV3.UITests; + +public sealed class Given_DashboardPage : TestBase +{ + [Test] + [Explicit("Requires a configured UI test target platform/device.")] + public async Task When_Navigating_to_dashboard_test_shows_key_surfaces() + { + // Allow the shell + login page to load. + await Task.Delay(5000); + + App.WaitForElement(q => q.All().Marked("DashboardTestButton")); + App.Tap(q => q.All().Marked("DashboardTestButton")); + + App.WaitForElement(q => q.All().Marked("DashboardNavView")); + App.WaitForElement(q => q.All().Marked("DashboardComposeBox")); + App.WaitForElement(q => q.All().Marked("DashboardComposeInput")); + + TakeScreenshot("Dashboard loaded"); + } + + [Test] + [Explicit("Requires a configured UI test target platform/device.")] + public async Task When_Navigating_to_dashboard_test_shows_notifications_card_surface() + { + // Allow the shell + login page to load. + await Task.Delay(5000); + + App.WaitForElement(q => q.All().Marked("DashboardTestButton")); + App.Tap(q => q.All().Marked("DashboardTestButton")); + + App.WaitForElement(q => q.All().Marked("DashboardCard.Notifications")); + TakeScreenshot("Notifications card surface"); + } + + [Test] + [Explicit("Requires a configured UI test target platform/device.")] + public async Task When_Navigating_to_dashboard_test_shows_my_activity_card_surface() + { + // Allow the shell + login page to load. + await Task.Delay(5000); + + App.WaitForElement(q => q.All().Marked("DashboardTestButton")); + App.Tap(q => q.All().Marked("DashboardTestButton")); + + App.WaitForElement(q => q.All().Marked("DashboardCard.MyRecentActivity")); + TakeScreenshot("My activity card surface"); + } + + [Test] + [Explicit("Requires a configured UI test target platform/device.")] + public async Task When_Narrow_can_open_sidebar() + { + await Task.Delay(5000); + + App.WaitForElement(q => q.All().Marked("DashboardTestButton")); + App.Tap(q => q.All().Marked("DashboardTestButton")); + App.WaitForElement(q => q.All().Marked("DashboardNavView")); + + // Portrait should trigger the Narrow visual state on phones/tablets. + // (On platforms where orientation is not supported, this is a no-op.) + try { App.SetOrientationPortrait(); } catch { } + + // In narrow mode the pane is closed; repo list shouldn't be visible until opened. + try + { + App.WaitForNoElement(q => q.All().Marked("DashboardRepoList"), timeout: TimeSpan.FromSeconds(2)); + } + catch + { + // If the platform doesn't support pane toggling, don't fail here. + } + + // NavigationView uses a standard toggle button automation id. + // If this fails on a target, it's a strong signal we need a dedicated AutomationId for the pane toggle. + App.Tap(q => q.All().Marked("TogglePaneButton")); + + App.WaitForElement(q => q.All().Marked("DashboardRepoList")); + TakeScreenshot("Sidebar opened (narrow)"); + } + + [Test] + [Explicit("Requires a configured UI test target platform/device.")] + public async Task When_Wide_compose_does_not_overlap_status_bar() + { + await Task.Delay(5000); + + App.WaitForElement(q => q.All().Marked("DashboardTestButton")); + App.Tap(q => q.All().Marked("DashboardTestButton")); + App.WaitForElement(q => q.All().Marked("DashboardComposeBox")); + + // Landscape should trigger the Wide visual state on phones/tablets. + try { App.SetOrientationLandscape(); } catch { } + + // Validate we can see the shell bottom status bar and that the compose box stays above it. + App.WaitForElement(q => q.All().Marked("ShellBottomStatusBar")); + + var compose = App.Query(q => q.All().Marked("DashboardComposeBox")).Single(); + var statusBar = App.Query(q => q.All().Marked("ShellBottomStatusBar")).Single(); + + var composeBottom = compose.Rect.Y + compose.Rect.Height; + var statusTop = statusBar.Rect.Y; + composeBottom.Should().BeLessThanOrEqualTo(statusTop + 1); + + TakeScreenshot("Compose above status bar (wide)"); + } +} diff --git a/JitHubV3.slnx b/JitHubV3.slnx index 1a88d66..a982928 100644 --- a/JitHubV3.slnx +++ b/JitHubV3.slnx @@ -50,6 +50,15 @@ + + + + + + + + + diff --git a/JitHubV3/App.xaml b/JitHubV3/App.xaml index 006f61a..9758ff1 100644 --- a/JitHubV3/App.xaml +++ b/JitHubV3/App.xaml @@ -9,10 +9,27 @@ + + + + + + + + + + + + + diff --git a/JitHubV3/App.xaml.cs b/JitHubV3/App.xaml.cs index 7501735..9884cfc 100644 --- a/JitHubV3/App.xaml.cs +++ b/JitHubV3/App.xaml.cs @@ -148,6 +148,17 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => { var baseUrl = context.Configuration["GitHub:ApiBaseUrl"]; @@ -197,7 +208,7 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) var authenticated = await auth.RefreshAsync(); if (authenticated) { - await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); + await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); } else { @@ -266,10 +277,12 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) views.Register( new ViewMap(ViewModel: typeof(ShellViewModel)), new ViewMap(), + new ViewMap(), new ViewMap(), new DataViewMap(), new DataViewMap(), new ViewMap(), + new ViewMap(), new DataViewMap() ); @@ -278,10 +291,12 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) Nested: [ new ("Login", View: views.FindByViewModel()), - new ("Main", View: views.FindByViewModel(), IsDefault:true), + new ("Dashboard", View: views.FindByViewModel(), IsDefault:true), + new ("Main", View: views.FindByViewModel()), new ("Issues", View: views.FindByViewModel()), new ("IssueConversation", View: views.FindByViewModel()), new ("MarkdownTest", View: views.FindByViewModel()), + new ("DashboardLayoutTest", View: views.FindByViewModel()), new ("Second", View: views.FindByViewModel()), ] ) diff --git a/JitHubV3/Authentication/GitHubAuthFlow.cs b/JitHubV3/Authentication/GitHubAuthFlow.cs index 2b8c5e7..bbf6c5f 100644 --- a/JitHubV3/Authentication/GitHubAuthFlow.cs +++ b/JitHubV3/Authentication/GitHubAuthFlow.cs @@ -36,7 +36,7 @@ internal static async Task> LoginAsync(IServiceProvi ? scopeFromCreds : "repo"; -#if WINDOWS || __WINDOWS__ || __SKIA__ +#if WINDOWS || __WINDOWS__ || __SKIA__ || JITHUB_DESKTOP // Desktop (WinAppSDK + Skia): system browser + loopback redirect. // This avoids WebAuthenticationBroker (not available on Skia) and avoids protocol activation races. await using var listener = new LoopbackHandoffListener("/oauth2/callback"); diff --git a/JitHubV3/JitHubV3.csproj b/JitHubV3/JitHubV3.csproj index a711da0..f9878fd 100644 --- a/JitHubV3/JitHubV3.csproj +++ b/JitHubV3/JitHubV3.csproj @@ -94,6 +94,14 @@ $(DefineConstants);USE_UITESTS + + + $(DefineConstants);JITHUB_DESKTOP + + @@ -102,6 +110,7 @@ + diff --git a/JitHubV3/Presentation/DashboardCardActionModel.cs b/JitHubV3/Presentation/DashboardCardActionModel.cs new file mode 100644 index 0000000..eb55a20 --- /dev/null +++ b/JitHubV3/Presentation/DashboardCardActionModel.cs @@ -0,0 +1,7 @@ +using System.Windows.Input; + +namespace JitHubV3.Presentation; + +public sealed record DashboardCardActionModel( + string Label, + ICommand? Command); diff --git a/JitHubV3/Presentation/DashboardCardId.cs b/JitHubV3/Presentation/DashboardCardId.cs new file mode 100644 index 0000000..b58d47f --- /dev/null +++ b/JitHubV3/Presentation/DashboardCardId.cs @@ -0,0 +1,59 @@ +namespace JitHubV3.Presentation; + +internal static class DashboardCardId +{ + // NOTE: DashboardViewModel syncs cards by CardId; these must be globally unique. + + public const long RepoRecentlyUpdatedIssues = 20_000_003; + public const long RepoSnapshot = 20_000_005; + + public const long RepoRecentActivityEmpty = 20_000_004; + public const long MyRecentActivityEmpty = 30_000_004; + public const long NotificationsEmpty = 30_000_003; + + private const long RepoRecentActivityItemBase = 20_000_004_000_000; + private const long MyRecentActivityItemBase = 30_000_004_000_000; + private const long NotificationsItemBase = 30_000_003_000_000; + + private const uint FeedCardIdModulo = 1_000_000; + + public static long NotificationsItem(string notificationId) + => FeedItem(NotificationsItemBase, notificationId); + + public static long MyRecentActivityItem(string eventId) + => FeedItem(MyRecentActivityItemBase, eventId); + + public static long RepoRecentActivityItem(string eventId) + => FeedItem(RepoRecentActivityItemBase, eventId); + + private static long FeedItem(long cardIdBase, string id) + { + if (id is null) + { + throw new ArgumentNullException(nameof(id)); + } + + // Must be stable across processes; do NOT use string.GetHashCode(). + var hash = StableHash32(id); + return cardIdBase + (hash % FeedCardIdModulo); + } + + private static uint StableHash32(string value) + { + // FNV-1a 32-bit + unchecked + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + + uint hash = offsetBasis; + for (var i = 0; i < value.Length; i++) + { + hash ^= value[i]; + hash *= prime; + } + + return hash; + } + } +} diff --git a/JitHubV3/Presentation/DashboardCardKind.cs b/JitHubV3/Presentation/DashboardCardKind.cs new file mode 100644 index 0000000..deb84ea --- /dev/null +++ b/JitHubV3/Presentation/DashboardCardKind.cs @@ -0,0 +1,15 @@ +namespace JitHubV3.Presentation; + +public enum DashboardCardKind +{ + Unknown = 0, + SelectedRepo = 1, + RepoIssuesSummary = 2, + RepoRecentlyUpdatedIssues = 3, + MyAssignedWork = 4, + MyReviewRequests = 5, + Notifications = 6, + MyRecentActivity = 7, + RepoRecentActivity = 8, + RepoSnapshot = 9, +} diff --git a/JitHubV3/Presentation/DashboardCardKindToAutomationIdConverter.cs b/JitHubV3/Presentation/DashboardCardKindToAutomationIdConverter.cs new file mode 100644 index 0000000..5e30543 --- /dev/null +++ b/JitHubV3/Presentation/DashboardCardKindToAutomationIdConverter.cs @@ -0,0 +1,31 @@ +using Microsoft.UI.Xaml.Data; + +namespace JitHubV3.Presentation; + +public sealed class DashboardCardKindToAutomationIdConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DashboardCardKind kind) + { + return kind switch + { + DashboardCardKind.SelectedRepo => "DashboardCard.SelectedRepo", + DashboardCardKind.RepoIssuesSummary => "DashboardCard.RepoIssuesSummary", + DashboardCardKind.RepoRecentlyUpdatedIssues => "DashboardCard.RepoRecentlyUpdatedIssues", + DashboardCardKind.MyAssignedWork => "DashboardCard.MyAssignedWork", + DashboardCardKind.MyReviewRequests => "DashboardCard.MyReviewRequests", + DashboardCardKind.Notifications => "DashboardCard.Notifications", + DashboardCardKind.MyRecentActivity => "DashboardCard.MyRecentActivity", + DashboardCardKind.RepoRecentActivity => "DashboardCard.RepoRecentActivity", + DashboardCardKind.RepoSnapshot => "DashboardCard.RepoSnapshot", + _ => "DashboardCard.Unknown", + }; + } + + return "DashboardCard.Unknown"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/JitHubV3/Presentation/DashboardCardModel.cs b/JitHubV3/Presentation/DashboardCardModel.cs new file mode 100644 index 0000000..cd085f6 --- /dev/null +++ b/JitHubV3/Presentation/DashboardCardModel.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace JitHubV3.Presentation; + +public sealed record DashboardCardModel( + long CardId, + DashboardCardKind Kind, + string Title, + string? Subtitle, + string? Summary, + int Importance = 0, + IReadOnlyList? Actions = null, + int? TintVariant = null); diff --git a/JitHubV3/Presentation/DashboardCardProviderTier.cs b/JitHubV3/Presentation/DashboardCardProviderTier.cs new file mode 100644 index 0000000..8e94c99 --- /dev/null +++ b/JitHubV3/Presentation/DashboardCardProviderTier.cs @@ -0,0 +1,9 @@ +namespace JitHubV3.Presentation; + +public enum DashboardCardProviderTier +{ + Local = 0, + SingleCallMultiCard = 1, + SingleCallSingleCard = 2, + MultiCallEnrichment = 3, +} diff --git a/JitHubV3/Presentation/DashboardContext.cs b/JitHubV3/Presentation/DashboardContext.cs new file mode 100644 index 0000000..a111e54 --- /dev/null +++ b/JitHubV3/Presentation/DashboardContext.cs @@ -0,0 +1,15 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using JitHub.GitHub.Abstractions.Models; + +namespace JitHubV3.Presentation; + +public sealed partial class DashboardContext : ObservableObject +{ + private RepoKey? _selectedRepo; + + public RepoKey? SelectedRepo + { + get => _selectedRepo; + set => SetProperty(ref _selectedRepo, value); + } +} diff --git a/JitHubV3/Presentation/DashboardLayoutTestPage.xaml b/JitHubV3/Presentation/DashboardLayoutTestPage.xaml new file mode 100644 index 0000000..2eecf96 --- /dev/null +++ b/JitHubV3/Presentation/DashboardLayoutTestPage.xaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + diff --git a/JitHubV3/Presentation/DashboardPage.xaml.cs b/JitHubV3/Presentation/DashboardPage.xaml.cs new file mode 100644 index 0000000..50cdfda --- /dev/null +++ b/JitHubV3/Presentation/DashboardPage.xaml.cs @@ -0,0 +1,58 @@ +namespace JitHubV3.Presentation; + +using System.Numerics; + +public sealed partial class DashboardPage : ActivatablePage +{ + public DashboardPage() + { + InitializeComponent(); + + Loaded += OnLoaded; + SizeChanged += (_, __) => UpdateWidthState(useTransitions: true); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + UpdateWidthState(useTransitions: false); + ApplyComposeElevation(); + } + + private void ApplyComposeElevation() + { + if (ComposeBox is null) + { + return; + } + + var elevation = ResolveDoubleResource("DashboardElevationCompose", fallback: 16); + ComposeBox.Translation = new Vector3(0, 0, (float)elevation); + } + + private void UpdateWidthState(bool useTransitions) + { + var threshold = ResolveGridToDeckMinWidth(); + var state = ActualWidth >= threshold ? "Wide" : "Narrow"; + + if (StateHost is not null) + { + VisualStateManager.GoToState(StateHost, state, useTransitions); + } + } + + private static double ResolveGridToDeckMinWidth() + { + return ResolveDoubleResource("DashboardGridToDeckMinWidth", fallback: 900); + } + + private static double ResolveDoubleResource(string key, double fallback) + { + var resources = Application.Current?.Resources; + if (resources is not null && resources.TryGetValue(key, out var value) && value is double width) + { + return width; + } + + return fallback; + } +} diff --git a/JitHubV3/Presentation/DashboardViewModel.cs b/JitHubV3/Presentation/DashboardViewModel.cs new file mode 100644 index 0000000..6534775 --- /dev/null +++ b/JitHubV3/Presentation/DashboardViewModel.cs @@ -0,0 +1,476 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Threading; +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; + +namespace JitHubV3.Presentation; + +public sealed partial class DashboardViewModel : ObservableObject, IActivatableViewModel +{ + private const string ReposCacheOperation = "github.repos.mine"; + private const int CardProviderMaxConcurrency = 4; + + private readonly ILogger _logger; + private readonly IDispatcher _dispatcher; + private readonly IGitHubRepositoryService _repositoryService; + private readonly ICacheEventBus _events; + private readonly StatusBarViewModel _statusBar; + private readonly IReadOnlyList _cardProviders; + + private CancellationTokenSource? _activeCts; + private CancellationTokenSource? _cardsCts; + private IDisposable? _subscription; + private int _loadGate; + + public DashboardContext Context { get; } = new(); + + public ObservableCollection Repositories { get; } = new(); + + public ObservableCollection Cards { get; } = new(); + + private bool _isLoadingCards; + public bool IsLoadingCards + { + get => _isLoadingCards; + set => SetProperty(ref _isLoadingCards, value); + } + + private bool _isLoadingRepos; + public bool IsLoadingRepos + { + get => _isLoadingRepos; + set => SetProperty(ref _isLoadingRepos, value); + } + + private int _repositoryCount; + public int RepositoryCount + { + get => _repositoryCount; + set => SetProperty(ref _repositoryCount, value); + } + + private long? _selectedRepositoryId; + public long? SelectedRepositoryId + { + get => _selectedRepositoryId; + set + { + if (!SetProperty(ref _selectedRepositoryId, value)) + { + return; + } + + OnSelectedRepositoryIdChanged(value); + } + } + + public IRelayCommand SelectRepoCommand { get; } + + public DashboardViewModel( + ILogger logger, + IDispatcher dispatcher, + IGitHubRepositoryService repositoryService, + ICacheEventBus events, + StatusBarViewModel statusBar, + IEnumerable cardProviders) + { + _logger = logger; + _dispatcher = dispatcher; + _repositoryService = repositoryService; + _events = events; + _statusBar = statusBar; + _cardProviders = (cardProviders ?? Enumerable.Empty()) + .OrderBy(p => p.Priority) + .ThenBy(p => p.ProviderId, StringComparer.Ordinal) + .ToArray(); + + SelectRepoCommand = new RelayCommand(SelectRepo); + } + + public string Title { get; } = "Dashboard"; + + public async Task ActivateAsync() + { + Deactivate(); + _activeCts = new CancellationTokenSource(); + + Context.PropertyChanged += OnContextPropertyChanged; + + _statusBar.Set( + message: "Dashboard", + isBusy: false, + isRefreshing: false, + freshness: DataFreshnessState.Unknown, + lastUpdatedAt: null); + + _ = RefreshCardsAsync(RefreshMode.CacheOnly, _activeCts.Token); + + await LoadReposAsync(_activeCts.Token); + } + + public void Deactivate() + { + Context.PropertyChanged -= OnContextPropertyChanged; + + _activeCts?.Cancel(); + _activeCts?.Dispose(); + _activeCts = null; + + _cardsCts?.Cancel(); + _cardsCts?.Dispose(); + _cardsCts = null; + + _subscription?.Dispose(); + _subscription = null; + + Interlocked.Exchange(ref _loadGate, 0); + IsLoadingRepos = false; + IsLoadingCards = false; + } + + private void OnContextPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(DashboardContext.SelectedRepo)) + { + return; + } + + var ct = _activeCts?.Token; + if (ct is null) + { + return; + } + + _ = RefreshCardsAsync(RefreshMode.CacheOnly, ct.Value); + } + + private void OnSelectedRepositoryIdChanged(long? value) + { + if (value is null) + { + Context.SelectedRepo = null; + return; + } + + var repo = Repositories.FirstOrDefault(r => r.Id == value.Value); + if (repo is null) + { + return; + } + + SelectRepoCommand.Execute(repo); + } + + private async Task LoadReposAsync(CancellationToken ct) + { + if (Interlocked.Exchange(ref _loadGate, 1) == 1) + { + return; + } + + var sawCacheUpdate = false; + _subscription?.Dispose(); + _subscription = _events.Subscribe(e => + { + if (e.Kind != CacheEventKind.Updated) + { + return; + } + + if (!string.Equals(e.Key.Operation, ReposCacheOperation, StringComparison.Ordinal)) + { + return; + } + + sawCacheUpdate = true; + _ = _dispatcher.ExecuteAsync(async _ => + { + try + { + var cached = await _repositoryService.GetMyRepositoriesAsync(RefreshMode.CacheOnly, ct); + ObservableCollectionSync.SyncById( + Repositories, + cached, + getId: x => x.Id, + shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next); + + RepositoryCount = Repositories.Count; + } + catch + { + // Ignore UI update failures during shutdown/cancellation. + } + }); + }); + + await _dispatcher.ExecuteAsync(_ => + { + IsLoadingRepos = true; + return ValueTask.CompletedTask; + }); + + _statusBar.Set( + message: "Loading repositories…", + isBusy: true, + isRefreshing: false, + freshness: DataFreshnessState.Unknown, + lastUpdatedAt: null); + + try + { + _logger.LogInformation("Loading repositories (RefreshMode={RefreshMode})", RefreshMode.PreferCacheThenRefresh); + var repos = await _repositoryService.GetMyRepositoriesAsync(RefreshMode.PreferCacheThenRefresh, ct); + + await _dispatcher.ExecuteAsync(_ => + { + ObservableCollectionSync.SyncById( + Repositories, + repos, + getId: x => x.Id, + shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next); + + RepositoryCount = Repositories.Count; + 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) + { + _logger.LogError(ex, "Failed to load repositories"); + _statusBar.Set( + message: "Failed to load repositories", + isBusy: false, + isRefreshing: false, + freshness: DataFreshnessState.Unknown); + } + finally + { + await _dispatcher.ExecuteAsync(_ => + { + IsLoadingRepos = false; + return ValueTask.CompletedTask; + }); + + _statusBar.Set(isBusy: false, isRefreshing: false); + Interlocked.Exchange(ref _loadGate, 0); + } + } + + private void SelectRepo(RepositorySummary? repo) + { + if (repo is null) + { + return; + } + + Context.SelectedRepo = new RepoKey(repo.OwnerLogin, repo.Name); + SelectedRepositoryId = repo.Id; + _statusBar.Set(message: $"Repo: {repo.OwnerLogin}/{repo.Name}"); + } + + private async Task RefreshCardsAsync(RefreshMode refresh, CancellationToken ct) + { + if (_cardProviders.Count == 0) + { + return; + } + + CancellationTokenSource? previousCts; + var nextCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + + previousCts = Interlocked.Exchange(ref _cardsCts, nextCts); + previousCts?.Cancel(); + previousCts?.Dispose(); + + var localCt = nextCts.Token; + + await _dispatcher.ExecuteAsync(_ => + { + IsLoadingCards = true; + return ValueTask.CompletedTask; + }); + + try + { + var plannedProviders = _cardProviders + .Select((provider, order) => new PlannedProvider(provider, GetTier(provider), order)) + .ToArray(); + + var cardsByProviderId = new Dictionary>(StringComparer.Ordinal); + var cardsLock = new object(); + + async Task PublishCardsAsync(CancellationToken publishCt) + { + List snapshot; + + lock (cardsLock) + { + snapshot = BuildSnapshot(plannedProviders, cardsByProviderId); + } + + await _dispatcher.ExecuteAsync(_ => + { + ObservableCollectionSync.SyncById( + Cards, + snapshot, + getId: x => x.CardId, + shouldReplace: (current, next) => current != next); + + return ValueTask.CompletedTask; + }); + } + + async Task RunProviderAsync(PlannedProvider planned, RefreshMode providerRefresh, CancellationToken providerCt) + { + providerCt.ThrowIfCancellationRequested(); + + try + { + var cards = await planned.Provider.GetCardsAsync(Context, providerRefresh, providerCt).ConfigureAwait(false); + + var normalized = Normalize(cards); + lock (cardsLock) + { + if (normalized.Count == 0) + { + cardsByProviderId.Remove(planned.Provider.ProviderId); + } + else + { + cardsByProviderId[planned.Provider.ProviderId] = normalized; + } + } + + await PublishCardsAsync(providerCt).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Ignore cancellation. + } + catch (Exception ex) + { + _logger.LogError(ex, "Dashboard card provider failed (ProviderId={ProviderId})", planned.Provider.ProviderId); + } + } + + // Wave A: always run the cheapest/high-yield providers first. + await StagedWorkScheduler.RunAsync( + plannedProviders, + tierSelector: p => (int)p.Tier, + maxTierInclusive: (int)DashboardCardProviderTier.SingleCallMultiCard, + maxConcurrency: CardProviderMaxConcurrency, + workAsync: (p, token) => RunProviderAsync(p, refresh, token), + ct: localCt).ConfigureAwait(false); + + if (refresh == RefreshMode.CacheOnly && !localCt.IsCancellationRequested) + { + // Wave B: background refresh for everything that can hit the network. + var backgroundProviders = plannedProviders + .Where(p => p.Tier != DashboardCardProviderTier.Local) + .ToArray(); + + _ = Task.Run(async () => + { + try + { + await StagedWorkScheduler.RunAsync( + backgroundProviders, + tierSelector: p => (int)p.Tier, + maxTierInclusive: (int)DashboardCardProviderTier.MultiCallEnrichment, + maxConcurrency: CardProviderMaxConcurrency, + workAsync: (p, token) => RunProviderAsync(p, RefreshMode.PreferCacheThenRefresh, token), + ct: localCt).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Ignore cancellation. + } + catch (Exception ex) + { + _logger.LogError(ex, "Background card refresh failed"); + } + }); + } + else + { + // If the caller explicitly requested a refresh, finish the remaining tiers as part of this call. + var remainingProviders = plannedProviders + .Where(p => p.Tier > DashboardCardProviderTier.SingleCallMultiCard) + .ToArray(); + + await StagedWorkScheduler.RunAsync( + remainingProviders, + tierSelector: p => (int)p.Tier, + maxTierInclusive: (int)DashboardCardProviderTier.MultiCallEnrichment, + maxConcurrency: CardProviderMaxConcurrency, + workAsync: (p, token) => RunProviderAsync(p, refresh, token), + ct: localCt).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Ignore cancellation. + } + finally + { + await _dispatcher.ExecuteAsync(_ => + { + IsLoadingCards = false; + return ValueTask.CompletedTask; + }); + } + } + + private static DashboardCardProviderTier GetTier(IDashboardCardProvider provider) + => provider is IStagedDashboardCardProvider staged + ? staged.Tier + : DashboardCardProviderTier.SingleCallSingleCard; + + private static IReadOnlyList Normalize(IReadOnlyList? cards) + { + if (cards is null || cards.Count == 0) + { + return Array.Empty(); + } + + return cards + .OrderByDescending(c => c.Importance) + .ThenBy(c => c.Title, StringComparer.Ordinal) + .ThenBy(c => c.CardId) + .ToArray(); + } + + private static List BuildSnapshot( + IReadOnlyList providerOrder, + IReadOnlyDictionary> cardsByProviderId) + { + var combined = new List(); + + foreach (var planned in providerOrder) + { + if (!cardsByProviderId.TryGetValue(planned.Provider.ProviderId, out var cards) || cards.Count == 0) + { + continue; + } + + combined.AddRange(cards); + } + + return combined; + } + + private sealed record PlannedProvider(IDashboardCardProvider Provider, DashboardCardProviderTier Tier, int Order); +} diff --git a/JitHubV3/Presentation/IDashboardCardProvider.cs b/JitHubV3/Presentation/IDashboardCardProvider.cs new file mode 100644 index 0000000..e2d6d14 --- /dev/null +++ b/JitHubV3/Presentation/IDashboardCardProvider.cs @@ -0,0 +1,16 @@ +using JitHub.GitHub.Abstractions.Refresh; +using System.Collections.Generic; + +namespace JitHubV3.Presentation; + +public interface IDashboardCardProvider +{ + string ProviderId { get; } + + int Priority { get; } + + Task> GetCardsAsync( + DashboardContext context, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHubV3/Presentation/IStagedDashboardCardProvider.cs b/JitHubV3/Presentation/IStagedDashboardCardProvider.cs new file mode 100644 index 0000000..2b63784 --- /dev/null +++ b/JitHubV3/Presentation/IStagedDashboardCardProvider.cs @@ -0,0 +1,6 @@ +namespace JitHubV3.Presentation; + +public interface IStagedDashboardCardProvider : IDashboardCardProvider +{ + DashboardCardProviderTier Tier { get; } +} diff --git a/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs deleted file mode 100644 index f7e9f0f..0000000 --- a/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs +++ /dev/null @@ -1,37 +0,0 @@ -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/CardTintVariantToBrushConverter.cs b/JitHubV3/Presentation/Infrastructure/CardTintVariantToBrushConverter.cs new file mode 100644 index 0000000..90a6118 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/CardTintVariantToBrushConverter.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace JitHubV3.Presentation.Infrastructure; + +public sealed class CardTintVariantToBrushConverter : IValueConverter +{ + private static readonly string[] _paletteKeys = + [ + "DashboardCardTintBrushNeutral", + "DashboardCardTintBrushAttention", + "DashboardCardTintBrushSuccess", + "DashboardCardTintBrushCaution", + "DashboardCardTintBrushCritical", + ]; + + public object? Convert(object value, Type targetType, object parameter, string language) + { + var variant = value as int?; + if (value is int i) + { + variant = i; + } + + if (variant is null) + { + return null; + } + + var normalized = variant.Value; + if (normalized < 0) + { + normalized = -normalized; + } + + var index = normalized % _paletteKeys.Length; + var key = _paletteKeys[index]; + + if (Application.Current?.Resources is { } resources && resources.TryGetValue(key, out var resource)) + { + return resource as Brush; + } + + return null; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/JitHubV3/Presentation/Infrastructure/CollectionNullOrEmptyToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/CollectionNullOrEmptyToVisibilityConverter.cs new file mode 100644 index 0000000..1cac917 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/CollectionNullOrEmptyToVisibilityConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace JitHubV3.Presentation; + +public sealed class CollectionNullOrEmptyToVisibilityConverter : IValueConverter +{ + public bool Invert { get; set; } + + public object Convert(object value, Type targetType, object parameter, string language) + { + var hasItems = false; + + if (value is ICollection collection) + { + hasItems = collection.Count > 0; + } + else if (value is IEnumerable enumerable) + { + var enumerator = enumerable.GetEnumerator(); + try + { + hasItems = enumerator.MoveNext(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } + + if (Invert) + { + hasItems = !hasItems; + } + + return hasItems ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/JitHubV3/Presentation/Infrastructure/DashboardElevation.cs b/JitHubV3/Presentation/Infrastructure/DashboardElevation.cs new file mode 100644 index 0000000..2caf367 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/DashboardElevation.cs @@ -0,0 +1,33 @@ +using System.Numerics; +using Microsoft.UI.Xaml; + +namespace JitHubV3.Presentation.Infrastructure; + +public static class DashboardElevation +{ + public static double GetZ(DependencyObject obj) + => (double)obj.GetValue(ZProperty); + + public static void SetZ(DependencyObject obj, double value) + => obj.SetValue(ZProperty, value); + + public static readonly DependencyProperty ZProperty = + DependencyProperty.RegisterAttached( + "Z", + typeof(double), + typeof(DashboardElevation), + new PropertyMetadata(0d, OnZChanged)); + + private static void OnZChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not UIElement element) + { + return; + } + + var z = e.NewValue is double value && double.IsFinite(value) ? value : 0d; + + // Use Translation.Z so ThemeShadow depth is consistent cross-platform. + element.Translation = new Vector3(0, 0, (float)z); + } +} diff --git a/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs index 8f89cd6..408d474 100644 --- a/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs +++ b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs @@ -12,7 +12,18 @@ public sealed partial class StatusBarViewModel : ObservableObject public void AttachToCurrentThread() { - _uiContext ??= SynchronizationContext.Current; + var current = SynchronizationContext.Current; + if (current is null) + { + return; + } + + // Always allow the UI layer (Shell) to re-attach on the real UI thread. + // This prevents a first call from a background thread from “poisoning” the context. + if (_uiContext is null || !ReferenceEquals(_uiContext, current)) + { + _uiContext = current; + } } public string? Message diff --git a/JitHubV3/Presentation/Infrastructure/StringNullOrWhiteSpaceToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/StringNullOrWhiteSpaceToVisibilityConverter.cs new file mode 100644 index 0000000..41ba02b --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/StringNullOrWhiteSpaceToVisibilityConverter.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace JitHubV3.Presentation; + +public sealed class StringNullOrWhiteSpaceToVisibilityConverter : IValueConverter +{ + public bool Invert { get; set; } + + public object Convert(object value, Type targetType, object parameter, string language) + { + var hasValue = value is string s && !string.IsNullOrWhiteSpace(s); + if (Invert) + { + hasValue = !hasValue; + } + + return hasValue ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotSupportedException(); + } +} diff --git a/JitHubV3/Presentation/LoginPage.xaml b/JitHubV3/Presentation/LoginPage.xaml index b6026d7..ea3452f 100644 --- a/JitHubV3/Presentation/LoginPage.xaml +++ b/JitHubV3/Presentation/LoginPage.xaml @@ -27,6 +27,16 @@ Command="{Binding Login}" HorizontalAlignment="Stretch" /> +