Skip to content

Commit e85bcc8

Browse files
authored
Merge branch 'dev' into codex/unet-workflow-support
2 parents c243848 + 67da830 commit e85bcc8

7 files changed

Lines changed: 593 additions & 161 deletions

File tree

StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs

Lines changed: 131 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)