From e58bdc3ed33f890653b6c0eeebd17e495aa9f037 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 22 Jun 2026 19:30:11 +0100 Subject: [PATCH 1/5] tweak CLAUDE.md --- apps/desktop/AStar.Dev.OneDrive.Sync.Client/CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/CLAUDE.md b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/CLAUDE.md index ba577673..603edbbe 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/CLAUDE.md +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/CLAUDE.md @@ -11,3 +11,7 @@ When a feature is implemented, the `appsettings.json` must be update so the `ASt ## Text Blocks All text displayed in the application must be supplied from the localisation service, NOT hard-coded. + +## Rules + +NEVER use `null` as a return type, ALWAYS use `Option` From d75eeed8fb53e248b1d03fbf984d857a2717d1b5 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 22 Jun 2026 19:43:50 +0100 Subject: [PATCH 2/5] test(search): add red tests for 500-result cap and deferred thumbnail (#677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 compile errors expected — IsCapped, CappedNoticeText, CancelThumbnailLoad not yet implemented. Co-Authored-By: Claude Sonnet 4.6 --- .../Search/GivenASyncedFileResultViewModel.cs | 22 +++++++ .../Search/GivenASyncedFileSearchViewModel.cs | 58 ++++++++++++++++++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileResultViewModel.cs b/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileResultViewModel.cs index ad4c9cce..fd6967e2 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileResultViewModel.cs +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client.Tests.Unit/Search/GivenASyncedFileResultViewModel.cs @@ -96,4 +96,26 @@ public void when_delete_button_text_is_read_then_it_delegates_to_localisation_se vm.DeleteButtonText.ShouldBe("Delete"); } + + [Fact] + public async Task when_cancel_thumbnail_load_is_called_during_load_then_thumbnail_stays_null() + { + string tmpPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".png"); + await File.WriteAllBytesAsync(tmpPath, PngFixtures.OneByOnePng, TestContext.Current.CancellationToken); + try + { + fileTypeClassifier.Classify(Arg.Any()).Returns(FileType.Image); + var vm = CreateSut(tmpPath); + + _ = vm.LoadThumbnailAsync(); + vm.CancelThumbnailLoad(); + await Task.Yield(); + + vm.Thumbnail.ShouldBeNull(); + } + finally + { + File.Delete(tmpPath); + } + } } 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 c93d834c..7c01bf7d 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 @@ -362,9 +362,8 @@ public async Task when_search_returns_image_result_and_file_exists_then_thumbnai var sut = CreateSut(); await sut.SearchCommand.ExecuteAsync(null); - await Task.Delay(500); // LoadThumbnailAsync is fire-and-forget from SearchAsync; allow background decode to complete - sut.Results[0].Thumbnail.ShouldNotBeNull(); + sut.Results[0].Thumbnail.ShouldBeNull(); } finally { @@ -448,4 +447,59 @@ public async Task when_selected_sort_order_index_is_3_then_criteria_sort_order_i captured!.SortOrder.ShouldBe(SearchSortOrder.SizeDescending); } + + private static IReadOnlyList MakeResults(int count) => Enumerable.Range(0, count).Select(_ => MakeResult()).ToList(); + + [Fact] + public async Task when_search_returns_500_results_then_is_capped_is_false() + { + repository.SearchAsync(Arg.Any(), Arg.Any()).Returns(MakeResults(500)); + var sut = CreateSut(); + + await sut.SearchCommand.ExecuteAsync(null); + + sut.IsCapped.ShouldBeFalse(); + } + + [Fact] + public async Task when_search_returns_501_results_then_is_capped_is_true() + { + repository.SearchAsync(Arg.Any(), Arg.Any()).Returns(MakeResults(501)); + var sut = CreateSut(); + + await sut.SearchCommand.ExecuteAsync(null); + + sut.IsCapped.ShouldBeTrue(); + } + + [Fact] + public async Task when_search_returns_501_results_then_results_collection_has_500_items() + { + repository.SearchAsync(Arg.Any(), Arg.Any()).Returns(MakeResults(501)); + var sut = CreateSut(); + + await sut.SearchCommand.ExecuteAsync(null); + + sut.Results.Count.ShouldBe(500); + } + + [Fact] + public async Task when_search_returns_501_results_then_result_count_is_500() + { + repository.SearchAsync(Arg.Any(), Arg.Any()).Returns(MakeResults(501)); + var sut = CreateSut(); + + await sut.SearchCommand.ExecuteAsync(null); + + sut.ResultCount.ShouldBe(500); + } + + [Fact] + public void when_instantiated_then_capped_notice_text_delegates_to_localisation_service() + { + loc.GetLocal("Search.ResultsCapped").Returns("Showing top 500 results. Refine your search to see more."); + var sut = CreateSut(); + + sut.CappedNoticeText.ShouldBe("Showing top 500 results. Refine your search to see more."); + } } From 258c9fa45254ef732de86fd75685c4b8751e0918 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Mon, 22 Jun 2026 20:03:24 +0100 Subject: [PATCH 3/5] feat(search): virtualise results list with 500-result soft cap (#677) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IsCapped / CappedNoticeText to SyncedFileSearchViewModel; results capped at 500 with localised notice when limit is hit - Replace WrapPanel with VirtualizingStackPanel in SyncedFileSearchView so only visible containers incur layout/render cost - Defer thumbnail loading to ContainerPrepared; cancel on ContainerClearing via CancellationTokenSource in SyncedFileResultViewModel - Bump ApplicationVersion 0.26.0 → 0.27.0 Closes #677 Co-Authored-By: Claude Sonnet 4.6 --- .../Assets/Localization/en-GB.json | 1 + .../Assets/Localization/en-US.json | 1 + .../Search/SyncedFileResultViewModel.cs | 48 ++++- .../Search/SyncedFileSearchView.axaml | 181 +++++++++--------- .../Search/SyncedFileSearchView.axaml.cs | 19 +- .../Search/SyncedFileSearchViewModel.cs | 16 +- .../appsettings.json | 2 +- 7 files changed, 173 insertions(+), 95 deletions(-) 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 dda099a2..7278b0a0 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 @@ -222,6 +222,7 @@ "Search.SortOrder.SizeAsc": "Size smallest first", "Search.SortOrder.SizeDesc": "Size largest first", "Search.Result.Delete.Button": "Delete", + "Search.ResultsCapped": "Showing top 500 results. Refine your search to see more.", "AccountSync.LocalSyncFolderLabel": "Local sync folder", "AccountSync.LocalSyncFolderPlaceholder": "e.g. /home/user/OneDrive/personal", "AccountSync.BrowseButton": "Browse ...", 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 f863094d..e37aee61 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 @@ -222,6 +222,7 @@ "Search.SortOrder.SizeAsc": "US-Size smallest first", "Search.SortOrder.SizeDesc": "US-Size largest first", "Search.Result.Delete.Button": "US-Delete", + "Search.ResultsCapped": "Showing top 500 results. Refine your search to see more.", "AccountSync.LocalSyncFolderLabel": "Local sync folder", "AccountSync.LocalSyncFolderPlaceholder": "e.g. /home/user/OneDrive/personal", "AccountSync.BrowseButton": "Browse ...", diff --git a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileResultViewModel.cs b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileResultViewModel.cs index 9d58cb1a..7897b640 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileResultViewModel.cs +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileResultViewModel.cs @@ -15,6 +15,7 @@ public sealed partial class SyncedFileResultViewModel : ObservableObject private readonly IUiDispatcher dispatcher; private readonly ILocalizationService loc; private readonly Func onDeleteAsync; + private CancellationTokenSource? thumbnailCts; public SyncedFileResultViewModel(SyncedItemSearchResult result, IFileTypeClassifier fileTypeClassifier, IFileOpenerService fileOpenerService, IUiDispatcher dispatcher, ILocalizationService loc, Func onDeleteAsync) { @@ -30,14 +31,28 @@ public SyncedFileResultViewModel(SyncedItemSearchResult result, IFileTypeClassif IsLocalPresent = File.Exists(result.LocalPath); } + /// File name extracted from the local path. public string FileName { get; } + + /// Human-readable file size (e.g. "1.2 MB"). public string FormattedSize { get; } + + /// Comma-separated list of tag names associated with this file. public string TagName { get; } + + /// Absolute local path of the synced file. public string LocalPath { get; } + + /// Classified type of the file (Image, Document, etc.). public FileType FileType { get; } + + /// True when the file exists at . public bool IsLocalPresent { get; } + + /// Card opacity — reduced when the local file is absent. public double CardOpacity => IsLocalPresent ? 1.0 : 0.4; + /// Localised label for the delete button. public string DeleteButtonText => loc.GetLocal("Search.Result.Delete.Button"); [ObservableProperty] @@ -49,18 +64,41 @@ public SyncedFileResultViewModel(SyncedItemSearchResult result, IFileTypeClassif [RelayCommand] private Task DeleteFileAsync(CancellationToken ct) => onDeleteAsync(ct); + /// + /// Cancels any in-progress thumbnail load and clears any thumbnail already set by a racing load. + /// Safe to call from any thread; idempotent. + /// + public void CancelThumbnailLoad() + { + var cts = Interlocked.Exchange(ref thumbnailCts, null); + cts?.Cancel(); + dispatcher.Post(() => Thumbnail = null); + } + + /// Loads a 150-px-wide thumbnail from when the file is a locally-present image. public async Task LoadThumbnailAsync() { + var previous = Interlocked.Exchange(ref thumbnailCts, null); + previous?.Cancel(); + + var cts = new CancellationTokenSource(); + thumbnailCts = cts; + if (!IsLocalPresent || FileType != FileType.Image) return; - var bitmap = await Task.Run(() => + try { - using var stream = File.OpenRead(LocalPath); - return Avalonia.Media.Imaging.Bitmap.DecodeToWidth(stream, 150); - }); + var bitmap = await Task.Run(() => + { + using var stream = File.OpenRead(LocalPath); + return Avalonia.Media.Imaging.Bitmap.DecodeToWidth(stream, 150); + }, cts.Token); - dispatcher.Post(() => Thumbnail = bitmap); + if (!cts.IsCancellationRequested) + dispatcher.Post(() => Thumbnail = bitmap); + } + catch (OperationCanceledException) { } } private static string FormatSize(long? bytes) => bytes switch 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 a2a4c563..4ced1fcb 100644 --- a/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml +++ b/apps/desktop/AStar.Dev.OneDrive.Sync.Client/Search/SyncedFileSearchView.axaml @@ -124,9 +124,12 @@ - + - + - - + + + + + + + - - - - + + + + + + + + - - - - - - - - - + - + - + + + + - - - - + + - - + + - - + + - - + +