Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface ActionBarFilterProps {

export interface ActionBarFilterHandle {
setFilterText: (text: string) => void;
focus: () => void;
}

/**
Expand Down Expand Up @@ -116,6 +117,9 @@ export const ActionBarFilter = forwardRef<ActionBarFilterHandle, ActionBarFilter
setFilterText(text);
inputRef.current.value = text;
props.onFilterTextChanged(text);
},
focus: () => {
inputRef.current.focus();
}
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -155,9 +155,11 @@ export const ListPackages = (props: React.PropsWithChildren<ViewsProps>) => {
const [debouncedQueryText, setDebouncedQueryText] = useState('');
const filterRef = useRef<ActionBarFilterHandle>(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) => {
Expand Down Expand Up @@ -195,6 +197,10 @@ export const ListPackages = (props: React.PropsWithChildren<ViewsProps>) => {
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) =>
Expand Down Expand Up @@ -337,11 +343,37 @@ export const ListPackages = (props: React.PropsWithChildren<ViewsProps>) => {

// 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[] => [
Expand All @@ -365,6 +397,11 @@ export const ListPackages = (props: React.PropsWithChildren<ViewsProps>) => {
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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:<value>` token in the filter input. Values are the user-facing
// tokens the input accepts; `name` is the implicit default.
Expand All @@ -24,6 +32,17 @@ const SORT_ORDER_TO_TOKEN: Record<PackagesSortOrder, string> = {
[PackagesSortOrder.NameDesc]: 'name-desc',
};

// Token value <-> PackagesFilter mapping for the `@filter:<value>` token.
// `all` is the implicit default and is never serialized into the input.
const FILTER_TOKEN_TO_FILTER: Record<string, PackagesFilter> = {
'outdated': PackagesFilter.Outdated,
};

const FILTER_TO_TOKEN: Record<PackagesFilter, string> = {
[PackagesFilter.All]: 'all',
[PackagesFilter.Outdated]: 'outdated',
};

/** Matches `@key` or `@key:value` tokens in the filter input. */
const TOKEN_REGEX = /@(\w+)(?::([\w-]+))?/gi;

Expand All @@ -35,6 +54,8 @@ export interface ParsedQuery {
readonly text: string;
/** Active sort order. */
readonly sort: PackagesSortOrder;
/** Active category filter. */
readonly filter: PackagesFilter;
}

/**
Expand All @@ -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 };
};

/**
Expand All @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {

Expand Down Expand Up @@ -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);
});
});
});
Loading