@@ -36,6 +36,14 @@ IModelIndexService modelIndexService
3636 private bool filterOptionsLoaded ;
3737 private bool searchQueued ;
3838 private int currentPage = 1 ;
39+ private CancellationTokenSource ? searchDebounceCts ;
40+
41+ /// <summary>
42+ /// How long to wait after the most recent filter change before firing the search.
43+ /// Kept settable (not a const) so unit tests can collapse it to <see cref="TimeSpan.Zero"/>
44+ /// instead of waiting hundreds of ms per assertion.
45+ /// </summary>
46+ public TimeSpan SearchDebounceInterval { get ; set ; } = TimeSpan . FromMilliseconds ( 300 ) ;
3947
4048 /// <summary>
4149 /// All search results we've fetched so far across pages, regardless of client-side filters.
@@ -95,6 +103,25 @@ IModelIndexService modelIndexService
95103 ? string . Empty
96104 : $ "{ AllBaseModels . Count ( x => x . IsSelected ) } of { AllBaseModels . Count } selected";
97105
106+ /// <summary>
107+ /// True when a partial selection is active — at least one selected and at least one
108+ /// deselected. The "all selected" stock state and the "none selected" degenerate state
109+ /// both render plain (no badge), since neither is a meaningful filter to surface.
110+ /// </summary>
111+ public bool HasModelTypeFilter =>
112+ AllModelTypes . Count > 0
113+ && AllModelTypes . Any ( x => x . IsSelected )
114+ && AllModelTypes . Any ( x => ! x . IsSelected ) ;
115+
116+ public bool HasBaseModelFilter =>
117+ AllBaseModels . Count > 0
118+ && AllBaseModels . Any ( x => x . IsSelected )
119+ && AllBaseModels . Any ( x => ! x . IsSelected ) ;
120+
121+ public int SelectedModelTypeCount => AllModelTypes . Count ( x => x . IsSelected ) ;
122+
123+ public int SelectedBaseModelCount => AllBaseModels . Count ( x => x . IsSelected ) ;
124+
98125 [ ObservableProperty ]
99126 private string searchQuery = string . Empty ;
100127
@@ -252,9 +279,51 @@ protected override async Task OnInitialLoadedAsync()
252279 EventManager . Instance . ModelIndexChanged += indexHandler ;
253280 AddDisposable ( Disposable . Create ( ( ) => EventManager . Instance . ModelIndexChanged -= indexHandler ) ) ;
254281
282+ // Cancel any pending debounced search when the VM is disposed.
283+ AddDisposable (
284+ Disposable . Create ( ( ) =>
285+ {
286+ searchDebounceCts ? . Cancel ( ) ;
287+ searchDebounceCts ? . Dispose ( ) ;
288+ searchDebounceCts = null ;
289+ } )
290+ ) ;
291+
292+ // Filter options (Model Type / Base Model dropdown contents) only come back
293+ // populated when the URL has no query string, so we have to fetch them with a
294+ // dedicated parameterless call before the first filtered search runs.
295+ await LoadFilterOptionsAsync ( ) ;
296+
255297 await SearchModels ( ) ;
256298 }
257299
300+ private async Task LoadFilterOptionsAsync ( )
301+ {
302+ if ( filterOptionsLoaded )
303+ {
304+ return ;
305+ }
306+
307+ try
308+ {
309+ var options = await civArchiveApiClient . GetFilterOptionsAsync ( ) ;
310+ ApplyFilterOptions ( options ) ;
311+ filterOptionsLoaded = true ;
312+ }
313+ catch ( Exception ex )
314+ {
315+ // Don't block the search itself — failure here just means the multi-select
316+ // dropdowns stay empty until the user reloads the page.
317+ NoResultsText = ex . Message ;
318+ }
319+ finally
320+ {
321+ // ApplyFilterOptions sets suppressSearch=true while it populates the option
322+ // collections; release it before the real search runs so user input flows.
323+ suppressSearch = false ;
324+ }
325+ }
326+
258327 partial void OnHideInstalledModelsChanged ( bool value ) => RebuildVisibleResults ( ) ;
259328
260329 private void OnLocalModelIndexChanged ( )
@@ -285,9 +354,60 @@ private void RebuildVisibleResults()
285354 OnPropertyChanged ( nameof ( IsEndOfResults ) ) ;
286355 }
287356
357+ /// <summary>
358+ /// Cancel any pending debounced search and start a new wait window. The actual
359+ /// search runs once the user pauses for <see cref="SearchDebounceInterval"/> —
360+ /// multiple rapid filter changes within that window collapse into a single fetch.
361+ /// </summary>
362+ private void RequestDebouncedSearch ( )
363+ {
364+ if ( suppressSearch )
365+ {
366+ return ;
367+ }
368+
369+ SaveSettings ( ) ;
370+
371+ if ( ! HasSearched )
372+ {
373+ return ;
374+ }
375+
376+ searchDebounceCts ? . Cancel ( ) ;
377+ searchDebounceCts ? . Dispose ( ) ;
378+
379+ var cts = new CancellationTokenSource ( ) ;
380+ searchDebounceCts = cts ;
381+
382+ _ = RunDebouncedSearchAsync ( cts . Token ) ;
383+ }
384+
385+ private async Task RunDebouncedSearchAsync ( CancellationToken token )
386+ {
387+ try
388+ {
389+ await Task . Delay ( SearchDebounceInterval , token ) ;
390+ }
391+ catch ( TaskCanceledException )
392+ {
393+ return ;
394+ }
395+
396+ if ( token . IsCancellationRequested )
397+ {
398+ return ;
399+ }
400+
401+ await SearchModels ( ) ;
402+ }
403+
288404 [ RelayCommand ]
289405 private async Task SearchModels ( bool isInfiniteScroll = false )
290406 {
407+ // Cancel any pending debounced search so an explicit invocation (search button,
408+ // ResetFilters, etc.) doesn't get shadowed by a redundant fire moments later.
409+ searchDebounceCts ? . Cancel ( ) ;
410+
291411 if ( IsLoading )
292412 {
293413 if ( ! isInfiniteScroll )
@@ -321,31 +441,6 @@ private async Task SearchModels(bool isInfiniteScroll = false)
321441 {
322442 var response = await civArchiveApiClient . SearchAsync ( filters ) ;
323443
324- if ( ! filterOptionsLoaded )
325- {
326- ApplyFilterOptions ( response . FilterOptions ) ;
327- filterOptionsLoaded = true ;
328-
329- // ApplyFilterOptions sets suppressSearch=true while populating the option
330- // collections. Only re-fetch if the user actually has saved selections
331- // (Types/BaseModels) that we couldn't apply on the first call — for a
332- // first-run user with no saved selections the initial response is already
333- // correct and a second fetch is wasted bandwidth.
334- var savedOptions = settingsManager . Settings . CivArchiveBrowserOptions ;
335- var hasSelections =
336- savedOptions . SelectedModelTypes . Count > 0 || savedOptions . SelectedBaseModels . Count > 0 ;
337-
338- if ( ! isInfiniteScroll && suppressSearch && hasSelections )
339- {
340- suppressSearch = false ;
341- response = await civArchiveApiClient . SearchAsync ( BuildFilters ( currentPage ) ) ;
342- }
343- else
344- {
345- suppressSearch = false ;
346- }
347- }
348-
349444 TotalHits = response . TotalHits ;
350445 currentPage = response . EffectiveFilters . Page ;
351446
@@ -613,11 +708,15 @@ IReadOnlyCollection<string> selectedValues
613708 {
614709 ApplyModelTypeFilter ( ) ;
615710 OnPropertyChanged ( nameof ( ModelTypeSelectionSummary ) ) ;
711+ OnPropertyChanged ( nameof ( HasModelTypeFilter ) ) ;
712+ OnPropertyChanged ( nameof ( SelectedModelTypeCount ) ) ;
616713 }
617714 else if ( ReferenceEquals ( target , AllBaseModels ) )
618715 {
619716 ApplyBaseModelFilter ( ) ;
620717 OnPropertyChanged ( nameof ( BaseModelSelectionSummary ) ) ;
718+ OnPropertyChanged ( nameof ( HasBaseModelFilter ) ) ;
719+ OnPropertyChanged ( nameof ( SelectedBaseModelCount ) ) ;
621720 }
622721 }
623722
@@ -756,7 +855,7 @@ private void SaveSettings()
756855 ) ;
757856 }
758857
759- private async void OnSelectableOptionChanged ( object ? sender , PropertyChangedEventArgs e )
858+ private void OnSelectableOptionChanged ( object ? sender , PropertyChangedEventArgs e )
760859 {
761860 if ( e . PropertyName != nameof ( BaseModelOptionViewModel . IsSelected ) )
762861 {
@@ -765,18 +864,12 @@ private async void OnSelectableOptionChanged(object? sender, PropertyChangedEven
765864
766865 OnPropertyChanged ( nameof ( ModelTypeSelectionSummary ) ) ;
767866 OnPropertyChanged ( nameof ( BaseModelSelectionSummary ) ) ;
867+ OnPropertyChanged ( nameof ( HasModelTypeFilter ) ) ;
868+ OnPropertyChanged ( nameof ( HasBaseModelFilter ) ) ;
869+ OnPropertyChanged ( nameof ( SelectedModelTypeCount ) ) ;
870+ OnPropertyChanged ( nameof ( SelectedBaseModelCount ) ) ;
768871
769- if ( suppressSearch )
770- {
771- return ;
772- }
773-
774- SaveSettings ( ) ;
775-
776- if ( HasSearched )
777- {
778- await SearchModels ( ) ;
779- }
872+ RequestDebouncedSearch ( ) ;
780873 }
781874
782875 partial void OnSelectedPlatformChanged ( NamedOption < CivArchivePlatformOption > ? value ) =>
@@ -793,18 +886,5 @@ partial void OnSelectedPlatformStatusChanged(NamedOption<CivArchivePlatformStatu
793886
794887 partial void OnSelectedKindChanged ( NamedOption < CivArchiveKindOption > ? value ) => TriggerFilterSearch ( ) ;
795888
796- private async void TriggerFilterSearch ( )
797- {
798- if ( suppressSearch )
799- {
800- return ;
801- }
802-
803- SaveSettings ( ) ;
804-
805- if ( HasSearched )
806- {
807- await SearchModels ( ) ;
808- }
809- }
889+ private void TriggerFilterSearch ( ) => RequestDebouncedSearch ( ) ;
810890}
0 commit comments