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:
@@ -345,3 +345,98 @@ body.resizing {
|
||||
background: var(--vscode-inputValidation-warningBackground);
|
||||
border-color: var(--vscode-inputValidation-warningBorder, transparent);
|
||||
}
|
||||
|
||||
/* --- filters popover ---------------------------------------------------- */
|
||||
|
||||
.popover-host {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
color: var(--vscode-menu-foreground, var(--vscode-foreground));
|
||||
background: var(--vscode-menu-background, var(--vscode-editorWidget-background));
|
||||
border: 1px solid var(--vscode-menu-border, var(--vscode-editorWidget-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.36));
|
||||
}
|
||||
|
||||
.popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover-heading {
|
||||
padding: 6px 6px 2px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.popover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.popover-row:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.popover-row.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.popover-row input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--vscode-checkbox-background, var(--vscode-button-background));
|
||||
}
|
||||
|
||||
.popover-label {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.popover-count {
|
||||
flex: 0 0 auto;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 6px 2px;
|
||||
border-top: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-panel-border));
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0;
|
||||
color: var(--vscode-textLink-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
+149
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user