From 2562316d901017c5bae9922b95cecd6b18396311 Mon Sep 17 00:00:00 2001 From: Brice Stacey Date: Thu, 23 Apr 2026 12:06:03 -0400 Subject: [PATCH] Add category filter to Packages pane filter menu Reimplements the Outdated filter that #12923 prototyped as a separate action-bar dropdown. It now lives as a "Filter" submenu alongside "Sort" in the existing filter-icon context menu and shares sort's token-based grammar: a new `@filter:` token drives category filtering. The default `All` filter is never serialized -- selecting it simply strips any existing token. After a menu selection, focus returns to the filter input and a trailing space is appended so the user can type free-text immediately without first pressing space. Requires a new focus() method on ActionBarFilterHandle. See #12923 --- .../browser/components/actionBarFilter.tsx | 4 + .../browser/components/listPackages.tsx | 49 +++++++++-- .../browser/components/packagesQuery.ts | 47 +++++++++- .../test/browser/packagesQuery.test.ts | 86 ++++++++++++++++++- 4 files changed, 177 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/positronActionBar/browser/components/actionBarFilter.tsx b/src/vs/platform/positronActionBar/browser/components/actionBarFilter.tsx index f5a16294945..a438b4de680 100644 --- a/src/vs/platform/positronActionBar/browser/components/actionBarFilter.tsx +++ b/src/vs/platform/positronActionBar/browser/components/actionBarFilter.tsx @@ -48,6 +48,7 @@ interface ActionBarFilterProps { export interface ActionBarFilterHandle { setFilterText: (text: string) => void; + focus: () => void; } /** @@ -116,6 +117,9 @@ export const ActionBarFilter = forwardRef { + inputRef.current.focus(); } })); diff --git a/src/vs/workbench/contrib/positronPackages/browser/components/listPackages.tsx b/src/vs/workbench/contrib/positronPackages/browser/components/listPackages.tsx index f8475943b5f..a20d33126a7 100644 --- a/src/vs/workbench/contrib/positronPackages/browser/components/listPackages.tsx +++ b/src/vs/workbench/contrib/positronPackages/browser/components/listPackages.tsx @@ -33,7 +33,7 @@ import { usePositronReactServicesContext } from '../../../../../base/browser/pos import { showCustomContextMenu, CustomContextMenuSubmenu, CustomContextMenuEntry } from '../../../../browser/positronComponents/customContextMenu/customContextMenu.js'; import { CustomContextMenuItem } from '../../../../browser/positronComponents/customContextMenu/customContextMenuItem.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { applySortToQuery, PackagesSortOrder, parseQuery } from './packagesQuery.js'; +import { applyFilterToQuery, applySortToQuery, PackagesFilter, PackagesSortOrder, parseQuery } from './packagesQuery.js'; const positronUninstallPackage = localize( 'positronUninstallPackage', @@ -155,9 +155,11 @@ export const ListPackages = (props: React.PropsWithChildren) => { const [debouncedQueryText, setDebouncedQueryText] = useState(''); const filterRef = useRef(null); - // Current sort derived from the immediate (non-debounced) query so the - // sort menu's checked state updates without waiting for the debounce. - const currentSort = useMemo(() => parseQuery(queryText).sort, [queryText]); + // Current sort and filter derived from the immediate (non-debounced) query + // so the menu's checked state updates without waiting for the debounce. + const currentQuery = useMemo(() => parseQuery(queryText), [queryText]); + const currentSort = currentQuery.sort; + const currentFilter = currentQuery.filter; // Clear selection when filter text changes. const handleFilterTextChanged = (text: string) => { @@ -195,6 +197,10 @@ export const ListPackages = (props: React.PropsWithChildren) => { const filteredPackages = useMemo(() => { let result = deduplicatedPackages; + if (debouncedQuery.filter === PackagesFilter.Outdated) { + result = result.filter((pkg) => pkg.latestVersion && pkg.latestVersion !== pkg.version); + } + if (debouncedQuery.text) { const lowerFilter = debouncedQuery.text.toLowerCase(); result = result.filter((pkg) => @@ -337,11 +343,37 @@ export const ListPackages = (props: React.PropsWithChildren) => { // Rewrite the filter input to reflect the selected sort. The input is the // source of truth, so updating it flows back through onFilterTextChanged - // and re-derives every dependent state. + // and re-derives every dependent state. A trailing space is appended so + // the user can immediately type free-text without first pressing space; + // focus returns to the input for the same reason. const selectSort = (sort: PackagesSortOrder) => { - filterRef.current?.setFilterText(applySortToQuery(queryText, sort)); + const newText = applySortToQuery(queryText, sort); + filterRef.current?.setFilterText(newText === '' ? '' : `${newText} `); + filterRef.current?.focus(); + }; + + // Rewrite the filter input to reflect the selected category filter. + const selectFilter = (filter: PackagesFilter) => { + const newText = applyFilterToQuery(queryText, filter); + filterRef.current?.setFilterText(newText === '' ? '' : `${newText} `); + filterRef.current?.focus(); }; + // Build the Filter submenu entries. Evaluated lazily so the checked state + // reflects the current input when the submenu is opened. + const filterSubmenuEntries = (): CustomContextMenuEntry[] => [ + new CustomContextMenuItem({ + label: localize('positronPackages.filterByAll', "All Packages"), + checked: currentFilter === PackagesFilter.All, + onSelected: () => selectFilter(PackagesFilter.All), + }), + new CustomContextMenuItem({ + label: localize('positronPackages.filterByOutdated', "Outdated"), + checked: currentFilter === PackagesFilter.Outdated, + onSelected: () => selectFilter(PackagesFilter.Outdated), + }), + ]; + // Build the Sort submenu entries. Evaluated lazily so the checked state // reflects the current input when the submenu is opened. const sortSubmenuEntries = (): CustomContextMenuEntry[] => [ @@ -365,6 +397,11 @@ export const ListPackages = (props: React.PropsWithChildren) => { popupAlignment: 'auto', minWidth: 160, entries: [ + new CustomContextMenuSubmenu({ + icon: 'list-filter', + label: localize('positronPackages.filterLabel', "Filter"), + entries: filterSubmenuEntries, + }), new CustomContextMenuSubmenu({ icon: 'arrow-swap-vertical', label: localize('positronPackages.sortLabel', "Sort"), diff --git a/src/vs/workbench/contrib/positronPackages/browser/components/packagesQuery.ts b/src/vs/workbench/contrib/positronPackages/browser/components/packagesQuery.ts index 833c410d330..3db1a80c8e9 100644 --- a/src/vs/workbench/contrib/positronPackages/browser/components/packagesQuery.ts +++ b/src/vs/workbench/contrib/positronPackages/browser/components/packagesQuery.ts @@ -11,6 +11,14 @@ export enum PackagesSortOrder { NameDesc = 'name-desc', } +/** + * PackagesFilter enum. + */ +export enum PackagesFilter { + All = 'all', + Outdated = 'outdated', +} + // Token value <-> PackagesSortOrder mapping used to (de)serialize the // `@sort:` token in the filter input. Values are the user-facing // tokens the input accepts; `name` is the implicit default. @@ -24,6 +32,17 @@ const SORT_ORDER_TO_TOKEN: Record = { [PackagesSortOrder.NameDesc]: 'name-desc', }; +// Token value <-> PackagesFilter mapping for the `@filter:` token. +// `all` is the implicit default and is never serialized into the input. +const FILTER_TOKEN_TO_FILTER: Record = { + 'outdated': PackagesFilter.Outdated, +}; + +const FILTER_TO_TOKEN: Record = { + [PackagesFilter.All]: 'all', + [PackagesFilter.Outdated]: 'outdated', +}; + /** Matches `@key` or `@key:value` tokens in the filter input. */ const TOKEN_REGEX = /@(\w+)(?::([\w-]+))?/gi; @@ -35,6 +54,8 @@ export interface ParsedQuery { readonly text: string; /** Active sort order. */ readonly sort: PackagesSortOrder; + /** Active category filter. */ + readonly filter: PackagesFilter; } /** @@ -45,18 +66,25 @@ export interface ParsedQuery { */ export const parseQuery = (query: string): ParsedQuery => { let sort: PackagesSortOrder = PackagesSortOrder.NameAsc; + let filter: PackagesFilter = PackagesFilter.All; const text = query.replace(TOKEN_REGEX, (_match, key: string, value: string | undefined) => { - if (key.toLowerCase() === 'sort' && value !== undefined) { + const lowerKey = key.toLowerCase(); + if (lowerKey === 'sort' && value !== undefined) { const order = SORT_TOKEN_TO_ORDER[value.toLowerCase()]; if (order !== undefined) { sort = order; } + } else if (lowerKey === 'filter' && value !== undefined) { + const parsed = FILTER_TOKEN_TO_FILTER[value.toLowerCase()]; + if (parsed !== undefined) { + filter = parsed; + } } return ''; }).replace(/\s+/g, ' ').trim(); - return { text, sort }; + return { text, sort, filter }; }; /** @@ -68,3 +96,18 @@ export const applySortToQuery = (query: string, sort: PackagesSortOrder): string const token = `@sort:${SORT_ORDER_TO_TOKEN[sort]}`; return stripped ? `${token} ${stripped}` : token; }; + +/** + * Returns a new filter input string with any existing `@filter:` token + * replaced by the token for the given category filter, preserving surrounding + * free-text. The default `All` filter is never serialized -- applying it + * simply strips any existing `@filter:` token. + */ +export const applyFilterToQuery = (query: string, filter: PackagesFilter): string => { + const stripped = query.replace(/@filter:[\w-]+/gi, '').replace(/\s+/g, ' ').trim(); + if (filter === PackagesFilter.All) { + return stripped; + } + const token = `@filter:${FILTER_TO_TOKEN[filter]}`; + return stripped ? `${token} ${stripped}` : token; +}; diff --git a/src/vs/workbench/contrib/positronPackages/test/browser/packagesQuery.test.ts b/src/vs/workbench/contrib/positronPackages/test/browser/packagesQuery.test.ts index 42cdbb8b632..c0efa879c59 100644 --- a/src/vs/workbench/contrib/positronPackages/test/browser/packagesQuery.test.ts +++ b/src/vs/workbench/contrib/positronPackages/test/browser/packagesQuery.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { applySortToQuery, PackagesSortOrder, parseQuery } from '../../browser/components/packagesQuery.js'; +import { applyFilterToQuery, applySortToQuery, PackagesFilter, PackagesSortOrder, parseQuery } from '../../browser/components/packagesQuery.js'; suite('packagesQuery', () => { @@ -134,4 +134,88 @@ suite('packagesQuery', () => { assert.strictEqual(parsed.sort, PackagesSortOrder.NameDesc); }); }); + + suite('parseQuery filter', () => { + test('empty input returns default All filter', () => { + const result = parseQuery(''); + assert.strictEqual(result.filter, PackagesFilter.All); + }); + + test('@filter:outdated sets Outdated filter', () => { + const result = parseQuery('@filter:outdated'); + assert.strictEqual(result.text, ''); + assert.strictEqual(result.filter, PackagesFilter.Outdated); + }); + + test('@filter:all explicitly sets All filter', () => { + const result = parseQuery('@filter:all'); + assert.strictEqual(result.text, ''); + assert.strictEqual(result.filter, PackagesFilter.All); + }); + + test('filter token matching is case-insensitive', () => { + const result = parseQuery('@FILTER:OUTDATED'); + assert.strictEqual(result.filter, PackagesFilter.Outdated); + }); + + test('filter token is stripped from free text', () => { + const result = parseQuery('dplyr @filter:outdated'); + assert.strictEqual(result.text, 'dplyr'); + assert.strictEqual(result.filter, PackagesFilter.Outdated); + }); + + test('unknown @filter: value is stripped and leaves default filter', () => { + const result = parseQuery('foo @filter:bogus bar'); + assert.strictEqual(result.text, 'foo bar'); + assert.strictEqual(result.filter, PackagesFilter.All); + }); + + test('filter and sort tokens coexist', () => { + const result = parseQuery('@filter:outdated @sort:name-desc dplyr'); + assert.strictEqual(result.text, 'dplyr'); + assert.strictEqual(result.filter, PackagesFilter.Outdated); + assert.strictEqual(result.sort, PackagesSortOrder.NameDesc); + }); + + test('multiple @filter: tokens: last one wins, all stripped', () => { + const result = parseQuery('foo @filter:all bar @filter:outdated baz'); + assert.strictEqual(result.text, 'foo bar baz'); + assert.strictEqual(result.filter, PackagesFilter.Outdated); + }); + }); + + suite('applyFilterToQuery', () => { + test('default All filter strips any existing token and returns bare text', () => { + assert.strictEqual(applyFilterToQuery('', PackagesFilter.All), ''); + assert.strictEqual(applyFilterToQuery('dplyr', PackagesFilter.All), 'dplyr'); + assert.strictEqual(applyFilterToQuery('@filter:outdated dplyr', PackagesFilter.All), 'dplyr'); + }); + + test('Outdated filter on empty input produces a bare token', () => { + assert.strictEqual(applyFilterToQuery('', PackagesFilter.Outdated), '@filter:outdated'); + }); + + test('Outdated filter with free text prepends the token', () => { + assert.strictEqual(applyFilterToQuery('dplyr', PackagesFilter.Outdated), '@filter:outdated dplyr'); + }); + + test('existing @filter: token is replaced', () => { + assert.strictEqual(applyFilterToQuery('@filter:all dplyr', PackagesFilter.Outdated), '@filter:outdated dplyr'); + }); + + test('replacement is case-insensitive on existing @filter: token', () => { + assert.strictEqual(applyFilterToQuery('@FILTER:ALL dplyr', PackagesFilter.Outdated), '@filter:outdated dplyr'); + }); + + test('non-@filter tokens are preserved', () => { + assert.strictEqual(applyFilterToQuery('@sort:name-desc dplyr', PackagesFilter.Outdated), '@filter:outdated @sort:name-desc dplyr'); + }); + + test('round-trip: applyFilterToQuery then parseQuery yields the same filter', () => { + const applied = applyFilterToQuery('dplyr', PackagesFilter.Outdated); + const parsed = parseQuery(applied); + assert.strictEqual(parsed.text, 'dplyr'); + assert.strictEqual(parsed.filter, PackagesFilter.Outdated); + }); + }); });