From 1f7804642462f2af36d738d3a05338f2b4f5d59d Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 22 Jun 2026 20:35:44 +0100 Subject: [PATCH 1/2] feat(search): virtualise results with ItemsRepeater + WrapLayout (#679) Replace ItemsControl + WrapPanel with ItemsRepeater + WrapLayout so only visible cards are realised. Wire thumbnail load/cancel to ElementPrepared and ElementClearing. Re-applies changes reverted from PR #680. Closes #679 Co-Authored-By: Claude Sonnet 4.6 --- .../Search/SyncedFileSearchView.axaml | 18 ++++++++---------- .../Search/SyncedFileSearchView.axaml.cs | 12 ++++++------ .../appsettings.json | 2 +- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml index 4a56fbac..334a2f79 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml @@ -179,14 +179,12 @@ HorizontalScrollBarVisibility="Disabled" MinHeight="0" Padding="16,8"> - - - - - - - + + + + + - - + + diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml.cs b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml.cs index 7c4531b1..51cbe7d1 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml.cs +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml.cs @@ -7,19 +7,19 @@ public partial class SyncedFileSearchView : UserControl public SyncedFileSearchView() { InitializeComponent(); - ResultsList.ContainerPrepared += OnContainerPrepared; - ResultsList.ContainerClearing += OnContainerClearing; + ResultsList.ElementPrepared += OnElementPrepared; + ResultsList.ElementClearing += OnElementClearing; } - private static void OnContainerPrepared(object? sender, ContainerPreparedEventArgs e) + private static void OnElementPrepared(object? sender, ItemsRepeaterElementPreparedEventArgs e) { - if (e.Container.DataContext is SyncedFileResultViewModel vm) + if (e.Element.DataContext is SyncedFileResultViewModel vm) _ = vm.LoadThumbnailAsync(); } - private static void OnContainerClearing(object? sender, ContainerClearingEventArgs e) + private static void OnElementClearing(object? sender, ItemsRepeaterElementClearingEventArgs e) { - if (e.Container.DataContext is SyncedFileResultViewModel vm) + if (e.Element.DataContext is SyncedFileResultViewModel vm) vm.CancelThumbnailLoad(); } } diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json index facd1cbe..5d8843b1 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json @@ -7,7 +7,7 @@ "AuthorityForMicrosoftAccountsOnly": "https://login.microsoftonline.com/consumers" }, "AStarDevOneDriveClient": { - "ApplicationVersion": "0.27.0", + "ApplicationVersion": "0.28.0", "ApplicationName": "AStar Dev OneDrive Sync Client", "CacheTag": 1, "UserPreferencesPath": "astar-dev/astar-dev-onedrive-client", From 2e49b44384b4420b3323f3013efb695b7c1272a8 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 22 Jun 2026 21:48:23 +0100 Subject: [PATCH 2/2] feat(search): show loading indicator while categories fetch in search view Search view now renders immediately on navigation; the tags/categories section displays "Loading categories..." while GetDistinctTagNamesAsync is in-flight instead of flashing the "no classifications" message. IsLoadingTags and ShowNoClassificationsHint are set atomically inside dispatcher.Post so the UI never sees a transient incorrect state. Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/GivenTheSearchLocalisationKeys.cs | 4 ++ .../Search/GivenASyncedFileSearchViewModel.cs | 45 +++++++++++++++++++ .../Assets/Localization/en-GB.json | 1 + .../Assets/Localization/en-US.json | 1 + .../Search/SyncedFileSearchView.axaml | 7 ++- .../Search/SyncedFileSearchViewModel.cs | 19 ++++++++ .../appsettings.json | 2 +- 7 files changed, 77 insertions(+), 2 deletions(-) diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Assets/GivenTheSearchLocalisationKeys.cs b/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Assets/GivenTheSearchLocalisationKeys.cs index d2e1f9c7..b88951c5 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Assets/GivenTheSearchLocalisationKeys.cs +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Assets/GivenTheSearchLocalisationKeys.cs @@ -58,4 +58,8 @@ public void when_read_then_search_no_results_key_exists() => [Fact] public void when_read_then_search_result_delete_button_key_exists() => RootElement().TryGetProperty("Search.Result.Delete.Button", out _).ShouldBeTrue(); + + [Fact] + public void when_read_then_search_tags_loading_key_exists() => + RootElement().TryGetProperty("Search.Tags.Loading", out _).ShouldBeTrue(); } diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileSearchViewModel.cs b/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileSearchViewModel.cs index 7c01bf7d..ab0c74e9 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileSearchViewModel.cs +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileSearchViewModel.cs @@ -502,4 +502,49 @@ public void when_instantiated_then_capped_notice_text_delegates_to_localisation_ sut.CappedNoticeText.ShouldBe("Showing top 500 results. Refine your search to see more."); } + + [Fact] + public void when_instantiated_then_tags_loading_text_delegates_to_localisation_service() + { + loc.GetLocal("Search.Tags.Loading").Returns("Loading categories..."); + var sut = CreateSut(); + + sut.TagsLoadingText.ShouldBe("Loading categories..."); + } + + [Fact] + public async Task when_view_is_activated_then_is_loading_tags_is_false_after_load() + { + repository.GetDistinctTagNamesAsync(TestAccountId, Arg.Any()).Returns([]); + var sut = new SyncedFileSearchViewModel(repository, fileOpenerService, fileTypeClassifier, accountRepository, dispatcher, loc); + sut.SetActiveAccount(TestAccountId); + + await sut.OnViewActivatedAsync(CancellationToken.None); + + sut.IsLoadingTags.ShouldBeFalse(); + } + + [Fact] + public async Task when_view_is_activated_with_no_tags_then_show_no_classifications_hint_is_true() + { + repository.GetDistinctTagNamesAsync(TestAccountId, Arg.Any()).Returns([]); + var sut = new SyncedFileSearchViewModel(repository, fileOpenerService, fileTypeClassifier, accountRepository, dispatcher, loc); + sut.SetActiveAccount(TestAccountId); + + await sut.OnViewActivatedAsync(CancellationToken.None); + + sut.ShowNoClassificationsHint.ShouldBeTrue(); + } + + [Fact] + public async Task when_view_is_activated_with_tags_then_show_no_classifications_hint_is_false() + { + repository.GetDistinctTagNamesAsync(TestAccountId, Arg.Any()).Returns(["Image", "Video"]); + var sut = new SyncedFileSearchViewModel(repository, fileOpenerService, fileTypeClassifier, accountRepository, dispatcher, loc); + sut.SetActiveAccount(TestAccountId); + + await sut.OnViewActivatedAsync(CancellationToken.None); + + sut.ShowNoClassificationsHint.ShouldBeFalse(); + } } diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-GB.json b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-GB.json index 7278b0a0..96465447 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-GB.json +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-GB.json @@ -211,6 +211,7 @@ "Search.MinSize.Label": "Min size (bytes)", "Search.MaxSize.Label": "Max size (bytes)", "Search.Tags.Label": "Tags", + "Search.Tags.Loading": "Loading categories...", "Search.Tags.NoClassifications": "No classifications found.", "Search.DuplicatesOnly.Label": "Duplicates only", "Search.Button": "Search", diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-US.json b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-US.json index e37aee61..ded0c957 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-US.json +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Assets/Localization/en-US.json @@ -211,6 +211,7 @@ "Search.MinSize.Label": "US-Min size (bytes)", "Search.MaxSize.Label": "US-Max size (bytes)", "Search.Tags.Label": "US-Tags", + "Search.Tags.Loading": "US-Loading categories...", "Search.Tags.NoClassifications": "US-No classifications found.", "Search.DuplicatesOnly.Label": "US-Duplicates only", "Search.Button": "US-Search", diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml index 334a2f79..f2338539 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml @@ -58,11 +58,16 @@ + + IsVisible="{Binding ShowNoClassificationsHint}"/> Results { get; } = []; public ObservableCollection SelectedTags { get; } = []; public ObservableCollection AvailableTags { get; } = []; @@ -76,6 +82,9 @@ public sealed partial class SyncedFileSearchViewModel(ISyncedItemRepository repo /// Localised "Tags" label. public string TagsLabelText => loc.GetLocal("Search.Tags.Label"); + /// Localised message shown while categories are being loaded. + public string TagsLoadingText => loc.GetLocal("Search.Tags.Loading"); + /// Localised message shown when no classifications exist for the active account. public string TagsNoClassificationsText => loc.GetLocal("Search.Tags.NoClassifications"); @@ -125,10 +134,18 @@ public async Task OnViewActivatedAsync(CancellationToken ct) if (activeAccountId is null) return; + IsLoadingTags = true; + ShowNoClassificationsHint = false; + var tags = await repository.GetDistinctTagNamesAsync(activeAccountId.Value, ct).ConfigureAwait(false); if (tags.Count <= cachedTagCount) + { + IsLoadingTags = false; + ShowNoClassificationsHint = AvailableTags.Count == 0; + return; + } cachedTagCount = tags.Count; @@ -137,6 +154,8 @@ public async Task OnViewActivatedAsync(CancellationToken ct) AvailableTags.Clear(); foreach (string tag in tags) AvailableTags.Add(tag); + IsLoadingTags = false; + ShowNoClassificationsHint = tags.Count == 0; }); } diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json index 5d8843b1..26ea5f5c 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/appsettings.json @@ -7,7 +7,7 @@ "AuthorityForMicrosoftAccountsOnly": "https://login.microsoftonline.com/consumers" }, "AStarDevOneDriveClient": { - "ApplicationVersion": "0.28.0", + "ApplicationVersion": "0.29.0", "ApplicationName": "AStar Dev OneDrive Sync Client", "CacheTag": 1, "UserPreferencesPath": "astar-dev/astar-dev-onedrive-client",