Skip to content

Commit 31b41b2

Browse files
mohnjilesclaude
andcommitted
Fix CivArchive browser filter bugs and compact layout
- Handle array-shaped platform echo in filter response — CivArchive returns pageProps.filters.platform as an array (e.g. ["civitai"]) once a non-default platform is selected, which threw at $.pageProps.filters.platform - Fetch filter options via parameterless /top-models.json — the endpoint returns empty baseModels/modelTypes the moment any query param is present, so the Model Type and Base Model dropdowns were silently empty - Collapse Rating/Status into icon-button flyouts and group Zoom / Hide Installed / Fit Images / Reset behind a single View overflow button so the filter row stays single-line at narrower widths - Show "(N)" count badge on Model Type / Base Model when partially selected - Debounce filter-driven searches at 300ms — rapid toggling no longer fires N back-to-back searches and trips CivArchive's rate limit - Cache SearchAsync / GetFilterOptionsAsync / GetModelDetailsAsync / ResolveFileUrlAsync responses with per-endpoint TTLs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9532bf2 commit 31b41b2

6 files changed

Lines changed: 532 additions & 161 deletions

File tree

StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs

Lines changed: 109 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ IModelIndexService modelIndexService
3636
private bool filterOptionsLoaded;
3737
private bool searchQueued;
3838
private int currentPage = 1;
39+
private DispatcherTimer? searchDebounceTimer;
3940

4041
/// <summary>
4142
/// All search results we've fetched so far across pages, regardless of client-side filters.
@@ -95,6 +96,25 @@ IModelIndexService modelIndexService
9596
? string.Empty
9697
: $"{AllBaseModels.Count(x => x.IsSelected)} of {AllBaseModels.Count} selected";
9798

99+
/// <summary>
100+
/// True when a partial selection is active — at least one selected and at least one
101+
/// deselected. The "all selected" stock state and the "none selected" degenerate state
102+
/// both render plain (no badge), since neither is a meaningful filter to surface.
103+
/// </summary>
104+
public bool HasModelTypeFilter =>
105+
AllModelTypes.Count > 0
106+
&& AllModelTypes.Any(x => x.IsSelected)
107+
&& AllModelTypes.Any(x => !x.IsSelected);
108+
109+
public bool HasBaseModelFilter =>
110+
AllBaseModels.Count > 0
111+
&& AllBaseModels.Any(x => x.IsSelected)
112+
&& AllBaseModels.Any(x => !x.IsSelected);
113+
114+
public int SelectedModelTypeCount => AllModelTypes.Count(x => x.IsSelected);
115+
116+
public int SelectedBaseModelCount => AllBaseModels.Count(x => x.IsSelected);
117+
98118
[ObservableProperty]
99119
private string searchQuery = string.Empty;
100120

@@ -252,9 +272,54 @@ protected override async Task OnInitialLoadedAsync()
252272
EventManager.Instance.ModelIndexChanged += indexHandler;
253273
AddDisposable(Disposable.Create(() => EventManager.Instance.ModelIndexChanged -= indexHandler));
254274

275+
// Debounce filter-driven searches so rapid toggles (e.g. picking a dozen base
276+
// models in a row) collapse into a single API call instead of N — CivArchive
277+
// 429s aggressively otherwise.
278+
searchDebounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
279+
searchDebounceTimer.Tick += OnSearchDebounceElapsed;
280+
AddDisposable(
281+
Disposable.Create(() =>
282+
{
283+
searchDebounceTimer.Stop();
284+
searchDebounceTimer.Tick -= OnSearchDebounceElapsed;
285+
})
286+
);
287+
288+
// Filter options (Model Type / Base Model dropdown contents) only come back
289+
// populated when the URL has no query string, so we have to fetch them with a
290+
// dedicated parameterless call before the first filtered search runs.
291+
await LoadFilterOptionsAsync();
292+
255293
await SearchModels();
256294
}
257295

296+
private async Task LoadFilterOptionsAsync()
297+
{
298+
if (filterOptionsLoaded)
299+
{
300+
return;
301+
}
302+
303+
try
304+
{
305+
var options = await civArchiveApiClient.GetFilterOptionsAsync();
306+
ApplyFilterOptions(options);
307+
filterOptionsLoaded = true;
308+
}
309+
catch (Exception ex)
310+
{
311+
// Don't block the search itself — failure here just means the multi-select
312+
// dropdowns stay empty until the user reloads the page.
313+
NoResultsText = ex.Message;
314+
}
315+
finally
316+
{
317+
// ApplyFilterOptions sets suppressSearch=true while it populates the option
318+
// collections; release it before the real search runs so user input flows.
319+
suppressSearch = false;
320+
}
321+
}
322+
258323
partial void OnHideInstalledModelsChanged(bool value) => RebuildVisibleResults();
259324

260325
private void OnLocalModelIndexChanged()
@@ -285,9 +350,42 @@ private void RebuildVisibleResults()
285350
OnPropertyChanged(nameof(IsEndOfResults));
286351
}
287352

353+
private void OnSearchDebounceElapsed(object? sender, EventArgs e)
354+
{
355+
searchDebounceTimer?.Stop();
356+
_ = SearchModels();
357+
}
358+
359+
/// <summary>
360+
/// Restart the debounce timer. The actual search runs once the user pauses for the
361+
/// timer's interval — multiple rapid filter changes within that window collapse into
362+
/// a single fetch.
363+
/// </summary>
364+
private void RequestDebouncedSearch()
365+
{
366+
if (suppressSearch)
367+
{
368+
return;
369+
}
370+
371+
SaveSettings();
372+
373+
if (!HasSearched)
374+
{
375+
return;
376+
}
377+
378+
searchDebounceTimer?.Stop();
379+
searchDebounceTimer?.Start();
380+
}
381+
288382
[RelayCommand]
289383
private async Task SearchModels(bool isInfiniteScroll = false)
290384
{
385+
// Cancel any pending debounced search so an explicit invocation (search button,
386+
// ResetFilters, etc.) doesn't get shadowed by a redundant fire 300ms later.
387+
searchDebounceTimer?.Stop();
388+
291389
if (IsLoading)
292390
{
293391
if (!isInfiniteScroll)
@@ -321,31 +419,6 @@ private async Task SearchModels(bool isInfiniteScroll = false)
321419
{
322420
var response = await civArchiveApiClient.SearchAsync(filters);
323421

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-
349422
TotalHits = response.TotalHits;
350423
currentPage = response.EffectiveFilters.Page;
351424

@@ -613,11 +686,15 @@ IReadOnlyCollection<string> selectedValues
613686
{
614687
ApplyModelTypeFilter();
615688
OnPropertyChanged(nameof(ModelTypeSelectionSummary));
689+
OnPropertyChanged(nameof(HasModelTypeFilter));
690+
OnPropertyChanged(nameof(SelectedModelTypeCount));
616691
}
617692
else if (ReferenceEquals(target, AllBaseModels))
618693
{
619694
ApplyBaseModelFilter();
620695
OnPropertyChanged(nameof(BaseModelSelectionSummary));
696+
OnPropertyChanged(nameof(HasBaseModelFilter));
697+
OnPropertyChanged(nameof(SelectedBaseModelCount));
621698
}
622699
}
623700

@@ -756,7 +833,7 @@ private void SaveSettings()
756833
);
757834
}
758835

759-
private async void OnSelectableOptionChanged(object? sender, PropertyChangedEventArgs e)
836+
private void OnSelectableOptionChanged(object? sender, PropertyChangedEventArgs e)
760837
{
761838
if (e.PropertyName != nameof(BaseModelOptionViewModel.IsSelected))
762839
{
@@ -765,18 +842,12 @@ private async void OnSelectableOptionChanged(object? sender, PropertyChangedEven
765842

766843
OnPropertyChanged(nameof(ModelTypeSelectionSummary));
767844
OnPropertyChanged(nameof(BaseModelSelectionSummary));
845+
OnPropertyChanged(nameof(HasModelTypeFilter));
846+
OnPropertyChanged(nameof(HasBaseModelFilter));
847+
OnPropertyChanged(nameof(SelectedModelTypeCount));
848+
OnPropertyChanged(nameof(SelectedBaseModelCount));
768849

769-
if (suppressSearch)
770-
{
771-
return;
772-
}
773-
774-
SaveSettings();
775-
776-
if (HasSearched)
777-
{
778-
await SearchModels();
779-
}
850+
RequestDebouncedSearch();
780851
}
781852

782853
partial void OnSelectedPlatformChanged(NamedOption<CivArchivePlatformOption>? value) =>
@@ -793,18 +864,5 @@ partial void OnSelectedPlatformStatusChanged(NamedOption<CivArchivePlatformStatu
793864

794865
partial void OnSelectedKindChanged(NamedOption<CivArchiveKindOption>? value) => TriggerFilterSearch();
795866

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-
}
867+
private void TriggerFilterSearch() => RequestDebouncedSearch();
810868
}

0 commit comments

Comments
 (0)