diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index d8ef99462a..0f75722302 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -120,7 +120,7 @@ public sealed partial class MainListPage : DynamicListPage, } else { - RaiseItemsChanged(); + RaiseItemsChanged(ListViewModel.IncrementalRefresh); } } @@ -331,7 +331,7 @@ public sealed partial class MainListPage : DynamicListPage, { _filteredItemsIncludesApps = _includeApps; ClearResults(); - RaiseItemsChanged(commands.Count); + RaiseItemsChanged(); return; } @@ -503,7 +503,7 @@ public sealed partial class MainListPage : DynamicListPage, return; } - RaiseItemsChanged(); + RaiseItemsChanged(ListViewModel.IncrementalRefresh); } }, token); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index da133125a8..859efd2afc 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -18,6 +18,8 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class ListViewModel : PageViewModel, IDisposable { + public const int IncrementalRefresh = -2; + private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler); private readonly Dictionary _vmCache = new(new ProxyReferenceEqualityComparer()); @@ -83,6 +85,11 @@ public partial class ListViewModel : PageViewModel, IDisposable private ListItemViewModel? _lastSelectedItem; + // Persists across cancelled FetchItems calls so a forceFirstItem=true + // intent is never lost when FetchItems(false) is cancelled by a + // subsequent FetchItems(true). + private volatile bool _forceFirstItemPending; + // For cancelling a deferred SafeSlowInit when the user navigates rapidly private CancellationTokenSource? _selectedItemCts; @@ -115,7 +122,7 @@ public partial class ListViewModel : PageViewModel, IDisposable } // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? - private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); + private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(args.TotalItems == IncrementalRefresh); protected override void OnSearchTextBoxUpdated(string searchTextBox) { @@ -191,8 +198,15 @@ public partial class ListViewModel : PageViewModel, IDisposable } //// Run on background thread, from InitializeAsync or Model_ItemsChanged - private void FetchItems() + private void FetchItems(bool keepSelection) { + // If this fetch should reset selection, remember that intent even if + // a later incremental fetch cancels us. + if (!keepSelection) + { + _forceFirstItemPending = true; + } + // Cancel any previous FetchItems operation _fetchItemsCancellationTokenSource?.Cancel(); _fetchItemsCancellationTokenSource?.Dispose(); @@ -382,7 +396,12 @@ public partial class ListViewModel : PageViewModel, IDisposable UpdateEmptyContent(); } - ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage)); + // Consume the pending flag on the UI thread so a + // forceFirstItem=true intent survives cancellation. + var forceFirst = _forceFirstItemPending; + _forceFirstItemPending = false; + + ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(forceFirstItem: IsRootPage && forceFirst)); _isLoading.Clear(); }); } @@ -661,7 +680,7 @@ public partial class ListViewModel : PageViewModel, IDisposable Filters?.InitializeProperties(); UpdateProperty(nameof(Filters)); - FetchItems(); + FetchItems(true); model.ItemsChanged += Model_ItemsChanged; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 3bef5d9dad..5591adfe00 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -231,25 +231,7 @@ public sealed partial class ListPage : Page, if (_scrollOnNextSelectionChange) { _scrollOnNextSelectionChange = false; - - var scrollTarget = li; - - // If the previous item is a separator, also scroll it into view to provide - // better context for the user - var index = ItemView.Items.IndexOf(li); - if (index > 0) - { - var prevItem = ItemView.Items[index - 1] as ListItemViewModel; - if (prevItem?.Type == ListItemType.SectionHeader) - { - scrollTarget = prevItem; - } - } - - if (scrollTarget is not null) - { - ItemView.ScrollIntoView(scrollTarget); - } + ScrollToItem(li); } // Automation notification for screen readers @@ -263,6 +245,28 @@ public sealed partial class ListPage : Page, } } + private void ScrollToItem(ListItemViewModel li) + { + var scrollTarget = li; + + // If the previous item is a separator, also scroll it into view to provide + // better context for the user + var index = ItemView.Items.IndexOf(li); + if (index > 0) + { + var prevItem = ItemView.Items[index - 1] as ListItemViewModel; + if (prevItem?.Type == ListItemType.SectionHeader) + { + scrollTarget = prevItem; + } + } + + if (scrollTarget is not null) + { + ItemView.ScrollIntoView(scrollTarget); + } + } + private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e) { if (e.OriginalSource is FrameworkElement element && @@ -717,6 +721,8 @@ public sealed partial class ListPage : Page, { using (SuppressSelectionChangedScope()) { + ListItemViewModel? stickyRestored = null; + if (!forceFirstItem && _stickySelectedItem is not null && items.Contains(_stickySelectedItem) && @@ -724,6 +730,7 @@ public sealed partial class ListPage : Page, { // Preserve sticky selection for nested dynamic updates. ItemView.SelectedItem = _stickySelectedItem; + stickyRestored = _stickySelectedItem; } else { @@ -741,7 +748,26 @@ public sealed partial class ListPage : Page, return; } - ResetScrollToTop(); + if (stickyRestored is not null) + { + ScrollToItem(stickyRestored); + } + else + { + ResetScrollToTop(); + } + }); + } + } + else + { + // Selection is valid and unchanged, just make sure the item is visible + if (_stickySelectedItem is ListItemViewModel li) + { + _ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + ItemView.UpdateLayout(); + ScrollToItem(li); }); } }