CmdPal: Filters for DynamicListPage? Yes, please. (#40783)

Closes: #40382

## To-do list

- [x] Add support for "single-select" filters to DynamicListPage
- [x] Filters can contain icons
- [x] Filter list can contain separators
- [x] Update Windows Services built-in extension to support filtering by
all, started, stopped, and pending services
- [x] Update SampleExtension dynamic list sample to filter.

## Example of filters in use

```C#
internal sealed partial class ServicesListPage : DynamicListPage
{
    public ServicesListPage()
    {
        Icon = Icons.ServicesIcon;
        Name = "Windows Services";

        var filters = new ServiceFilters();
        filters.PropChanged += Filters_PropChanged;
        Filters = filters;
    }

    private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();

    public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged();

    public override IListItem[] GetItems()
    {
       // ServiceHelper.Search knows how to filter based on the CurrentFilterIds provided
        var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterIds).ToArray();

        return items;
    }
}

public partial class ServiceFilters : Filters
{
    public ServiceFilters()
    {
        // This would be a default selection. Not providing this will cause the filter
        // control to display the "Filter" placeholder text.
        CurrentFilterIds = ["all"];
    }

    public override IFilterItem[] GetFilters()
    {
        return [
            new Filter() { Id = "all", Name = "All Services" },
            new Separator(),
            new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon },
            new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon },
            new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon },
        ];
    }
}
```

## Current example of behavior


https://github.com/user-attachments/assets/2e325763-ad3a-4445-bbe2-a840df08d0b3

---------

Co-authored-by: Mike Griese <migrie@microsoft.com>
This commit is contained in:
Michael Jolley
2025-08-21 05:40:09 -05:00
committed by GitHub
parent 1a798e03cd
commit 69dc1d5e18
26 changed files with 646 additions and 33 deletions

View File

@@ -131,7 +131,7 @@ internal sealed partial class AppListItem : ListItem
var newCommands = new List<IContextItem>();
newCommands.AddRange(commands);
newCommands.Add(new SeparatorContextItem());
newCommands.Add(new Separator());
// 0x50 = P
// Full key chord would be Ctrl+P

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Helpers;
public static class ServiceHelper
{
public static IEnumerable<ListItem> Search(string search)
public static IEnumerable<ListItem> Search(string search, string filterId)
{
var services = ServiceController.GetServices().OrderBy(s => s.DisplayName);
IEnumerable<ServiceController> serviceList = [];
@@ -44,6 +44,21 @@ public static class ServiceHelper
serviceList = servicesStartsWith.Concat(servicesContains);
}
switch (filterId)
{
case "running":
serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Running);
break;
case "stopped":
serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Stopped);
break;
case "paused":
serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Paused);
break;
case "all":
break;
}
var result = serviceList.Select(s =>
{
var serviceResult = ServiceResult.CreateServiceController(s);

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.WindowsServices;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class ServiceFilters : Filters
{
public ServiceFilters()
{
CurrentFilterId = "all";
}
public override IFilterItem[] GetFilters()
{
return [
new Filter() { Id = "all", Name = "All Services" },
new Separator(),
new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon },
new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon },
new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon },
];
}
}

View File

@@ -16,13 +16,19 @@ internal sealed partial class ServicesListPage : DynamicListPage
{
Icon = Icons.ServicesIcon;
Name = "Windows Services";
var filters = new ServiceFilters();
filters.PropChanged += Filters_PropChanged;
Filters = filters;
}
private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0);
public override IListItem[] GetItems()
{
var items = ServiceHelper.Search(SearchText).ToArray();
var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterId).ToArray();
return items;
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.CommandPalette.Extensions;
@@ -16,9 +17,14 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage
Icon = new IconInfo(string.Empty);
Name = "Dynamic List";
IsLoading = true;
var filters = new SampleFilters();
filters.PropChanged += Filters_PropChanged;
Filters = filters;
}
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(newSearch.Length);
private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged();
public override IListItem[] GetItems()
{
@@ -28,6 +34,23 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage
items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }];
}
if (!string.IsNullOrEmpty(Filters.CurrentFilterId))
{
switch (Filters.CurrentFilterId)
{
case "mod2":
items = items.Where((item, index) => (index + 1) % 2 == 0).ToArray();
break;
case "mod3":
items = items.Where((item, index) => (index + 1) % 3 == 0).ToArray();
break;
case "all":
default:
// No filtering
break;
}
}
if (items.Length > 0)
{
items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box";
@@ -36,3 +59,18 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage
return items;
}
}
#pragma warning disable SA1402 // File may only contain a single type
public partial class SampleFilters : Filters
#pragma warning restore SA1402 // File may only contain a single type
{
public override IFilterItem[] GetFilters()
{
return
[
new Filter() { Id = "all", Name = "All" },
new Filter() { Id = "mod2", Name = "Every 2nd", Icon = new IconInfo("2") },
new Filter() { Id = "mod3", Name = "Every 3rd", Icon = new IconInfo("3") },
];
}
}

View File

@@ -81,7 +81,7 @@ internal sealed partial class SampleListPage : ListPage
Title = "I'm a second command",
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1),
},
new SeparatorContextItem(),
new Separator(),
new CommandContextItem(
new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3
{