Filter panel results by project and hide test code

Roadmap item 3. One Filters button opens a menu holding both, rather than
two more controls in a toolbar that is already crowded in a docked panel:

- Hide test code, with the count of test results.
- A checkbox per project with its result count, plus All / None.

The button shows how many filters are active and the summary line reads
"12 of 43 references" while anything is filtered. Projects are persisted as
an *exclusion* list, so a project that only appears in a later search shows
up instead of being silently hidden. Test hiding seeds from
coloredReferences.hideTests on first results, then follows the panel.

What counts as test code is coloredReferences.testPattern, a regular
expression matched against the workspace-relative path and against the
containing project name. Matching on names turned out to need care: the
first pattern classified Latest.cs as a test because "Latest" contains
"test". The built-in pattern now requires the name to start at a boundary,
or an uppercase T for the CamelCase FooTests.cs form, and covers test/tests
directories, Tests.cs, foo_test.go, test_foo.py, foo.spec.ts and projects
named Something.Tests. An invalid configured regex is reported once and
ignored rather than throwing per search.

These filters narrow the panel only; the editor view still lists everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 17:50:02 +02:00
co-authored by Claude Opus 5
parent 3e8ece8ed7
commit 3df78ba490
8 changed files with 468 additions and 6 deletions
+149 -1
View File
@@ -28,7 +28,9 @@
{ value: 'write', label: 'Writes', title: 'Show only references that write the symbol' },
];
/** @type {{symbol: string, languageId: string, rows: any[], fileCount: number}} */
/** @type {{symbol: string, languageId: string, rows: any[], fileCount: number,
* projects?: {name: string, count: number}[], testCount?: number,
* hideTestsDefault?: boolean}} */
let data = { symbol: '', languageId: '', rows: [], fileCount: 0 };
const restored = vscode.getState() || {};
@@ -38,6 +40,11 @@
group: restored.group !== false,
filter: restored.filter || '',
kind: KIND_FILTERS.some(k => k.value === restored.kind) ? restored.kind : 'all',
// Projects are stored as an *exclusion* list, so a project that only turns up in
// a later search is visible by default rather than silently filtered out.
hiddenProjects: Array.isArray(restored.hiddenProjects) ? restored.hiddenProjects : [],
// undefined until the first results arrive, then seeded from the setting.
hideTests: typeof restored.hideTests === 'boolean' ? restored.hideTests : undefined,
widths: Object.assign({}, restored.widths),
collapsed: Object.assign({}, restored.collapsed),
selectedId: restored.selectedId,
@@ -47,6 +54,8 @@
summary: /** @type {HTMLElement} */ (document.getElementById('summary')),
filter: /** @type {HTMLInputElement} */ (document.getElementById('filter')),
kindFilter: /** @type {HTMLElement} */ (document.getElementById('kind-filter')),
filters: /** @type {HTMLButtonElement} */ (document.getElementById('filters')),
filtersMenu: /** @type {HTMLElement} */ (document.getElementById('filters-menu')),
group: /** @type {HTMLButtonElement} */ (document.getElementById('toggle-group')),
refresh: /** @type {HTMLButtonElement} */ (document.getElementById('refresh')),
table: /** @type {HTMLElement} */ (document.getElementById('table')),
@@ -404,6 +413,12 @@
if (state.kind !== 'all' && row.kind !== state.kind) {
return false;
}
if (state.hideTests && row.isTest) {
return false;
}
if (state.hiddenProjects.indexOf(row.project) >= 0) {
return false;
}
if (!needle) {
return true;
}
@@ -638,6 +653,128 @@
renderRows();
});
// -----------------------------------------------------------------------
// Project / test filters
// -----------------------------------------------------------------------
function activeFilterCount() {
return state.hiddenProjects.filter(
p => (data.projects || []).some(entry => entry.name === p)).length +
(state.hideTests ? 1 : 0);
}
function refreshFiltersButton() {
const active = activeFilterCount();
el.filters.textContent = active > 0 ? `Filters (${active})` : 'Filters';
el.filters.setAttribute('aria-pressed', String(active > 0));
}
function checkboxRow(label, checked, onToggle, count) {
const row = document.createElement('label');
row.className = 'popover-row';
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = checked;
box.addEventListener('change', () => onToggle(box.checked));
row.appendChild(box);
const text = document.createElement('span');
text.className = 'popover-label';
text.textContent = label;
row.appendChild(text);
if (count !== undefined) {
const badge = document.createElement('span');
badge.className = 'popover-count';
badge.textContent = String(count);
row.appendChild(badge);
}
return row;
}
function buildFiltersMenu() {
const menu = el.filtersMenu;
menu.textContent = '';
const tests = data.testCount || 0;
const testRow = checkboxRow('Hide test code', !!state.hideTests, checked => {
state.hideTests = checked;
saveState();
buildFiltersMenu();
refreshFiltersButton();
renderRows();
}, tests);
if (tests === 0) {
testRow.classList.add('disabled');
testRow.title = 'No results are in test code';
}
menu.appendChild(testRow);
const projects = data.projects || [];
if (projects.length > 1) {
const heading = document.createElement('div');
heading.className = 'popover-heading';
heading.textContent = 'Projects';
menu.appendChild(heading);
for (const project of projects) {
const shown = state.hiddenProjects.indexOf(project.name) < 0;
menu.appendChild(checkboxRow(project.name || '(no project)', shown, checked => {
state.hiddenProjects = state.hiddenProjects.filter(p => p !== project.name);
if (!checked) {
state.hiddenProjects.push(project.name);
}
saveState();
buildFiltersMenu();
refreshFiltersButton();
renderRows();
}, project.count));
}
const actions = document.createElement('div');
actions.className = 'popover-actions';
for (const [label, hidden] of [['All', []], ['None', projects.map(p => p.name)]]) {
const button = document.createElement('button');
button.className = 'link-button';
button.textContent = /** @type {string} */ (label);
button.addEventListener('click', () => {
state.hiddenProjects = /** @type {string[]} */ (hidden).slice();
saveState();
buildFiltersMenu();
refreshFiltersButton();
renderRows();
});
actions.appendChild(button);
}
menu.appendChild(actions);
}
}
function openFilters(open) {
el.filtersMenu.hidden = !open;
el.filters.setAttribute('aria-expanded', String(open));
if (open) {
buildFiltersMenu();
const first = el.filtersMenu.querySelector('input');
if (first) {
/** @type {HTMLElement} */ (first).focus();
}
}
}
el.filters.addEventListener('click', event => {
event.stopPropagation();
openFilters(el.filtersMenu.hidden);
});
document.addEventListener('click', event => {
const target = /** @type {HTMLElement} */ (event.target);
if (!el.filtersMenu.hidden && !el.filtersMenu.contains(target) && target !== el.filters) {
openFilters(false);
}
});
function buildKindFilter() {
el.kindFilter.textContent = '';
for (const option of KIND_FILTERS) {
@@ -671,6 +808,9 @@
event.preventDefault();
el.filter.focus();
el.filter.select();
} else if (event.key === 'Escape' && !el.filtersMenu.hidden) {
openFilters(false);
el.filters.focus();
} else if (event.key === 'Escape' && document.activeElement === el.filter) {
el.filter.value = '';
state.filter = '';
@@ -692,6 +832,13 @@
if (message.type === 'results') {
const newSymbol = message.symbol !== data.symbol;
data = message;
if (typeof state.hideTests !== 'boolean') {
state.hideTests = !!data.hideTestsDefault;
}
refreshFiltersButton();
if (!el.filtersMenu.hidden) {
buildFiltersMenu();
}
// Row ids are positional, so a selection carried over from another symbol
// would point at an unrelated reference.
if (newSymbol || !data.rows.some(r => r.id === state.selectedId)) {
@@ -716,6 +863,7 @@
applyPalette();
el.filter.value = state.filter;
buildKindFilter();
refreshFiltersButton();
el.group.setAttribute('aria-pressed', String(state.group));
el.body.tabIndex = 0;
computeAutoWidths();