Files
maxandClaude Opus 5 3df78ba490 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>
2026-09-07 17:50:02 +02:00

875 lines
33 KiB
JavaScript

// @ts-check
/**
* Colored References — results panel.
*
* Renders reference results as a table with resizable, sortable columns.
* All state (sort order, grouping, column widths, filter) is round-tripped through
* the webview state so it survives the panel being hidden and restored.
*/
(function () {
'use strict';
const vscode = acquireVsCodeApi();
// `share` is the fraction of the panel a column gets before anyone drags it.
const COLUMNS = [
{ key: 'code', label: 'Code', share: 0.36, min: 120, align: 'left' },
{ key: 'file', label: 'File', share: 0.18, min: 80, align: 'left' },
// Minimums here are what the column's own header needs, not just its content.
{ key: 'line', label: 'Line', share: 0.05, min: 58, align: 'right' },
{ key: 'kind', label: 'Kind', share: 0.07, min: 62, align: 'left' },
{ key: 'project', label: 'Project', share: 0.14, min: 60, align: 'left' },
{ key: 'member', label: 'Containing member', share: 0.20, min: 80, align: 'left' },
];
const KIND_FILTERS = [
{ value: 'all', label: 'All', title: 'Show reads and writes' },
{ value: 'read', label: 'Reads', title: 'Show only references that read the symbol' },
{ value: 'write', label: 'Writes', title: 'Show only references that write the symbol' },
];
/** @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() || {};
let state = {
sortKey: restored.sortKey || 'file',
sortDir: restored.sortDir === 'desc' ? 'desc' : 'asc',
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,
};
const el = {
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')),
head: /** @type {HTMLElement} */ (document.getElementById('head')),
body: /** @type {HTMLElement} */ (document.getElementById('body')),
empty: /** @type {HTMLElement} */ (document.getElementById('empty')),
};
function saveState() {
vscode.setState(state);
}
// -----------------------------------------------------------------------
// Theme-aware token palette
// -----------------------------------------------------------------------
// Token colours are not exposed to webviews as CSS variables, so approximate the
// stock Dark+/Light+ hues. What each token *is* comes from the language server
// (see TOKEN_CLASS); only the hues are ours, so this cannot match a custom theme.
const PALETTES = {
dark: {
'fg-default': '#d4d4d4', comment: '#6a9955', string: '#ce9178',
keyword: '#569cd6', control: '#c586c0', number: '#b5cea8',
type: '#4ec9b0', call: '#dcdcaa', variable: '#9cdcfe',
},
light: {
'fg-default': '#000000', comment: '#008000', string: '#a31515',
keyword: '#0000ff', control: '#af00db', number: '#098658',
type: '#267f99', call: '#795e26', variable: '#001080',
},
contrast: {
'fg-default': '#ffffff', comment: '#7ca668', string: '#ce9178',
keyword: '#569cd6', control: '#c586c0', number: '#b5cea8',
type: '#4ec9b0', call: '#dcdcaa', variable: '#9cdcfe',
},
};
// Language-server token type -> palette class. Types the server reports but we have
// no distinct colour for fall through to the default foreground.
const TOKEN_CLASS = {
comment: 'tok-comment',
string: 'tok-string', verbatimString: 'tok-string', stringEscapeCharacter: 'tok-string',
number: 'tok-number',
keyword: 'tok-keyword', modifier: 'tok-keyword', preprocessorKeyword: 'tok-keyword',
controlKeyword: 'tok-control',
operator: 'tok-punct', punctuation: 'tok-punct',
class: 'tok-type', struct: 'tok-type', interface: 'tok-type', enum: 'tok-type',
delegate: 'tok-type', typeParameter: 'tok-type', type: 'tok-type', record: 'tok-type',
recordStruct: 'tok-type', namespace: 'tok-type',
method: 'tok-call', function: 'tok-call', extensionMethod: 'tok-call',
property: 'tok-variable', field: 'tok-variable', variable: 'tok-variable',
parameter: 'tok-variable', enumMember: 'tok-variable', event: 'tok-variable',
constant: 'tok-variable', local: 'tok-variable',
};
function applyPalette() {
const cls = document.body.className;
const palette = cls.indexOf('vscode-high-contrast-light') >= 0 ? PALETTES.light
: cls.indexOf('vscode-high-contrast') >= 0 ? PALETTES.contrast
: cls.indexOf('vscode-light') >= 0 ? PALETTES.light
: PALETTES.dark;
for (const name of Object.keys(palette)) {
document.documentElement.style.setProperty('--cr-' + name, palette[name]);
}
}
// -----------------------------------------------------------------------
// Tiny tokenizer, good enough for one-line snippets
// -----------------------------------------------------------------------
const KEYWORDS = new Set((
'abstract as async await base bool byte char class const decimal default delegate double dynamic ' +
'enum event explicit extern false fixed float implicit in init int interface internal is let lock ' +
'long nameof namespace new null object operator out override params partial private protected public ' +
'readonly record ref sbyte sealed short sizeof stackalloc static string struct this true typeof uint ' +
'ulong unchecked unsafe ushort using value var virtual void volatile where with ' +
'function const let type def fn impl mut pub struct trait unsigned auto extends implements ' +
'export import from declare any number boolean unknown never'
).split(' '));
const CONTROL = new Set((
'break case catch continue do else finally for foreach goto if return switch throw try while yield ' +
'match loop elif except raise pass'
).split(' '));
const TOKEN = new RegExp([
'(\\/\\/[^\\n]*|#[^\\n]*|--[^\\n]*|\\/\\*.*?(?:\\*\\/|$))', // 1 comment
'(@?"(?:[^"\\\\]|\\\\.)*"?|\'(?:[^\'\\\\]|\\\\.)*\'?|`(?:[^`\\\\]|\\\\.)*`?)', // 2 string
'(\\b\\d[\\w.]*\\b)', // 3 number
'([A-Za-z_$][\\w$]*)', // 4 word
'(\\s+)', // 5 space
'([^\\w\\s])', // 6 punctuation
].join('|'), 'gs');
/**
* Splits a line into [className, text] pairs.
* @param {string} text
* @returns {[string, string][]}
*/
function tokenize(text) {
/** @type {[string, string][]} */
const out = [];
TOKEN.lastIndex = 0;
let match;
while ((match = TOKEN.exec(text)) !== null) {
const [all, comment, str, num, word, space, punct] = match;
if (comment) {
out.push(['tok-comment', all]);
} else if (str) {
out.push(['tok-string', all]);
} else if (num) {
out.push(['tok-number', all]);
} else if (word) {
const next = text[TOKEN.lastIndex];
out.push([
KEYWORDS.has(word) ? 'tok-keyword'
: CONTROL.has(word) ? 'tok-control'
: next === '(' ? 'tok-call'
: /^[A-Z]/.test(word) ? 'tok-type'
: '',
all,
]);
} else if (space) {
out.push(['', all]);
} else if (punct) {
out.push(['tok-punct', all]);
} else {
out.push(['', all]);
}
if (match.index === TOKEN.lastIndex) {
TOKEN.lastIndex++; // never spin on a zero-width match
}
}
return out;
}
/**
* Turns the language server's token spans into [className, text] pairs, leaving the
* gaps between them (punctuation, whitespace) unclassified.
* @param {string} text
* @param {{start: number, end: number, type: string}[]} spans
* @returns {[string, string][]}
*/
function fromSpans(text, spans) {
/** @type {[string, string][]} */
const out = [];
let at = 0;
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
const start = Math.max(at, Math.min(span.start, text.length));
const end = Math.max(start, Math.min(span.end, text.length));
if (start > at) {
out.push(['', text.slice(at, start)]);
}
if (end > start) {
out.push([TOKEN_CLASS[span.type] || '', text.slice(start, end)]);
}
at = end;
}
if (at < text.length) {
out.push(['', text.slice(at)]);
}
return out;
}
/**
* Renders a code line: tokenized, with the referenced symbol boxed.
* @param {string} text
* @param {[number, number][]} hits
* @param {{start: number, end: number, type: string}[]} [spans]
*/
function renderCode(text, hits, spans) {
const fragment = document.createDocumentFragment();
// Split token boundaries on hit boundaries so a hit never straddles two spans.
const cuts = new Set([0, text.length]);
for (const [start, end] of hits || []) {
cuts.add(start);
cuts.add(end);
}
// Real token types when the server served them, the local guess otherwise.
const tokens = spans && spans.length > 0 ? fromSpans(text, spans) : tokenize(text);
let offset = 0;
for (const [cls, piece] of tokens) {
let from = offset;
const to = offset + piece.length;
const inner = [...cuts].filter(c => c > from && c < to).sort((a, b) => a - b);
for (const cut of inner.concat([to])) {
appendPiece(fragment, text.slice(from, cut), cls, from, hits || []);
from = cut;
}
offset = to;
}
return fragment;
}
function appendPiece(parent, text, cls, start, hits) {
if (!text) {
return;
}
const span = document.createElement('span');
if (cls) {
span.className = cls;
}
span.textContent = text;
const inHit = hits.some(([s, e]) => start >= s && start < e);
if (inHit) {
const box = document.createElement('span');
box.className = 'hit';
box.appendChild(span);
parent.appendChild(box);
} else {
parent.appendChild(span);
}
}
// -----------------------------------------------------------------------
// Columns
// -----------------------------------------------------------------------
/** Widths derived from the panel width, used until the user drags a column. */
let autoWidths = {};
function computeAutoWidths() {
const available = el.table.clientWidth || 900;
autoWidths = {};
for (const column of COLUMNS) {
autoWidths[column.key] = Math.max(column.min, Math.round(available * column.share));
}
}
function width(column) {
const stored = state.widths[column.key];
return typeof stored === 'number' ? stored : autoWidths[column.key] ?? column.min;
}
function applyGrid() {
// Every column is a fixed track so its edge can be dragged; a trailing filler
// track soaks up the slack so rows still span the full width. When the fixed
// tracks are wider than the panel, #table scrolls the header and rows together.
const template = COLUMNS.map(c => `${width(c)}px`).join(' ') + ' minmax(0, 1fr)';
document.documentElement.style.setProperty('--cr-grid', template);
}
function customized() {
return Object.keys(state.widths).length > 0;
}
function buildHead() {
el.head.textContent = '';
for (const column of COLUMNS) {
const th = document.createElement('div');
th.className = 'th';
th.dataset.key = column.key;
th.tabIndex = 0;
th.setAttribute('role', 'columnheader');
th.title = `Sort by ${column.label}`;
const label = document.createElement('span');
label.className = 'label';
label.textContent = column.label;
const arrow = document.createElement('span');
arrow.className = 'arrow';
arrow.textContent = state.sortDir === 'desc' ? '▼' : '▲';
if (state.sortKey === column.key) {
th.dataset.sorted = state.sortDir;
th.setAttribute('aria-sort', state.sortDir === 'desc' ? 'descending' : 'ascending');
}
if (column.align === 'right') {
th.style.justifyContent = 'flex-end';
}
th.appendChild(label);
th.appendChild(arrow);
const grip = document.createElement('div');
grip.className = 'grip';
grip.addEventListener('mousedown', event => startResize(event, column));
grip.addEventListener('dblclick', event => {
event.stopPropagation();
delete state.widths[column.key];
saveState();
applyGrid();
});
th.appendChild(grip);
th.addEventListener('click', () => sortBy(column.key));
th.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
sortBy(column.key);
}
});
el.head.appendChild(th);
}
}
function startResize(event, column) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = width(column);
document.body.classList.add('resizing');
function move(e) {
state.widths[column.key] = Math.max(column.min, Math.round(startWidth + (e.clientX - startX)));
applyGrid();
}
function up() {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
document.body.classList.remove('resizing');
saveState();
}
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
}
function sortBy(key) {
if (state.sortKey === key) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortKey = key;
state.sortDir = 'asc';
}
saveState();
buildHead();
renderRows();
}
// -----------------------------------------------------------------------
// Rows
// -----------------------------------------------------------------------
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
function compare(a, b) {
const key = state.sortKey;
let result;
if (key === 'line') {
result = a.line - b.line;
} else if (key === 'file') {
result = collator.compare(a.relPath, b.relPath) || (a.line - b.line);
} else {
result = collator.compare(a[key] || '', b[key] || '');
}
if (result === 0) {
// Stable, predictable secondary order.
result = collator.compare(a.relPath, b.relPath) || (a.line - b.line) || (a.col - b.col);
}
return state.sortDir === 'desc' ? -result : result;
}
function matches(row, needle) {
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;
}
return (row.code + ' ' + row.relPath + ' ' + row.project + ' ' + row.member + ' ' + row.kind)
.toLowerCase().indexOf(needle) >= 0;
}
function visibleRows() {
const needle = state.filter.trim().toLowerCase();
return data.rows.filter(r => matches(r, needle)).sort(compare);
}
function cell(className, text) {
const div = document.createElement('div');
div.className = 'td ' + className;
if (text !== undefined) {
div.textContent = text;
}
return div;
}
function rowElement(row) {
const div = document.createElement('div');
div.className = 'row' + (row.id === state.selectedId ? ' selected' : '');
div.dataset.id = String(row.id);
div.setAttribute('role', 'row');
const code = cell('code');
code.appendChild(renderCode(row.code, row.hits, row.spans));
code.title = row.code;
div.appendChild(code);
const file = cell('file');
const name = document.createElement('span');
name.className = 'name';
name.textContent = row.file;
file.appendChild(name);
if (row.dir) {
file.appendChild(document.createTextNode(' ' + row.dir));
}
file.title = row.relPath;
div.appendChild(file);
div.appendChild(cell('line', String(row.line)));
const kind = cell('kind');
const badge = document.createElement('span');
badge.className = 'kind-badge kind-' + row.kind;
badge.textContent = row.kind;
kind.appendChild(badge);
div.appendChild(kind);
div.appendChild(cell('project', row.project));
const member = cell('member', row.member);
member.title = row.member;
div.appendChild(member);
return div;
}
function groupElement(relPath, project, count, collapsed) {
const div = document.createElement('div');
div.className = 'group' + (collapsed ? ' collapsed' : '');
div.dataset.group = relPath;
const twisty = document.createElement('span');
twisty.className = 'twisty';
twisty.textContent = collapsed ? '▶' : '▼';
div.appendChild(twisty);
if (project) {
const tag = document.createElement('span');
tag.className = 'project';
tag.textContent = '[' + project + ']';
div.appendChild(tag);
}
const label = document.createElement('span');
label.textContent = relPath;
div.appendChild(label);
const badge = document.createElement('span');
badge.className = 'count';
badge.textContent = '(' + count + ')';
div.appendChild(badge);
div.title = 'Click to collapse, double-click to open the file';
return div;
}
function renderRows() {
const rows = visibleRows();
const fragment = document.createDocumentFragment();
if (state.group) {
/** @type {Map<string, any[]>} */
const groups = new Map();
for (const row of rows) {
const list = groups.get(row.relPath);
if (list) {
list.push(row);
} else {
groups.set(row.relPath, [row]);
}
}
for (const [relPath, list] of groups) {
const collapsed = !!state.collapsed[relPath];
fragment.appendChild(groupElement(relPath, list[0].project, list.length, collapsed));
if (!collapsed) {
for (const row of list) {
fragment.appendChild(rowElement(row));
}
}
}
} else {
for (const row of rows) {
fragment.appendChild(rowElement(row));
}
}
el.body.textContent = '';
el.body.appendChild(fragment);
el.empty.hidden = rows.length > 0;
el.empty.textContent = data.rows.length === 0
? 'No references.'
: `No result matches “${state.filter}”.`;
const shown = rows.length;
const total = data.rows.length;
const writes = data.writeCount || 0;
el.summary.textContent = '';
const strong = document.createElement('b');
strong.textContent = data.symbol;
el.summary.appendChild(document.createTextNode(shown === total ? '' : `${shown} of `));
el.summary.appendChild(document.createTextNode(`${total} reference${total === 1 ? '' : 's'} to `));
el.summary.appendChild(strong);
el.summary.appendChild(document.createTextNode(
` in ${data.fileCount} file${data.fileCount === 1 ? '' : 's'}` +
(writes > 0 ? ` · ${writes} write${writes === 1 ? '' : 's'}` : '')));
}
// -----------------------------------------------------------------------
// Interaction
// -----------------------------------------------------------------------
function select(rowDiv, reveal) {
const previous = el.body.querySelector('.row.selected');
if (previous) {
previous.classList.remove('selected');
}
if (!rowDiv) {
state.selectedId = undefined;
saveState();
return;
}
rowDiv.classList.add('selected');
state.selectedId = Number(rowDiv.dataset.id);
saveState();
if (reveal) {
rowDiv.scrollIntoView({ block: 'nearest' });
}
}
function open(id, preview) {
vscode.postMessage({ type: 'open', id: Number(id), preview: !!preview });
}
el.body.addEventListener('click', event => {
const target = /** @type {HTMLElement} */ (event.target);
const group = target.closest('.group');
if (group) {
const key = group.dataset.group;
state.collapsed[key] = !state.collapsed[key];
saveState();
renderRows();
return;
}
const row = target.closest('.row');
if (row) {
select(row, false);
open(row.dataset.id, true);
}
});
el.body.addEventListener('dblclick', event => {
const target = /** @type {HTMLElement} */ (event.target);
const group = target.closest('.group');
if (group) {
vscode.postMessage({ type: 'openFile', relPath: group.dataset.group });
return;
}
const row = target.closest('.row');
if (row) {
open(row.dataset.id, false);
}
});
el.body.addEventListener('keydown', event => {
const rows = [...el.body.querySelectorAll('.row')];
if (rows.length === 0) {
return;
}
const current = el.body.querySelector('.row.selected');
let index = current ? rows.indexOf(current) : -1;
switch (event.key) {
case 'ArrowDown': index = Math.min(rows.length - 1, index + 1); break;
case 'ArrowUp': index = Math.max(0, index <= 0 ? 0 : index - 1); break;
case 'Home': index = 0; break;
case 'End': index = rows.length - 1; break;
case 'PageDown': index = Math.min(rows.length - 1, index + 15); break;
case 'PageUp': index = Math.max(0, index - 15); break;
case 'Enter':
if (current) {
open(current.dataset.id, false);
}
event.preventDefault();
return;
default:
return;
}
event.preventDefault();
const next = /** @type {HTMLElement} */ (rows[index]);
select(next, true);
open(next.dataset.id, true);
});
el.filter.addEventListener('input', () => {
state.filter = el.filter.value;
saveState();
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) {
const button = document.createElement('button');
button.className = 'toolbar-button segment';
button.textContent = option.label;
button.title = option.title;
button.dataset.kind = option.value;
button.setAttribute('aria-pressed', String(state.kind === option.value));
button.addEventListener('click', () => {
state.kind = option.value;
saveState();
buildKindFilter();
renderRows();
});
el.kindFilter.appendChild(button);
}
}
el.group.addEventListener('click', () => {
state.group = !state.group;
el.group.setAttribute('aria-pressed', String(state.group));
saveState();
renderRows();
});
el.refresh.addEventListener('click', () => vscode.postMessage({ type: 'refresh' }));
document.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
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 = '';
saveState();
renderRows();
el.body.focus();
} else if (event.key === 'F5') {
event.preventDefault();
vscode.postMessage({ type: 'refresh' });
}
});
new MutationObserver(applyPalette).observe(document.body, {
attributes: true, attributeFilter: ['class'],
});
window.addEventListener('message', event => {
const message = event.data;
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)) {
state.selectedId = data.rows.length > 0 ? data.rows[0].id : undefined;
}
renderRows();
el.body.focus();
}
});
// -----------------------------------------------------------------------
// Boot
// -----------------------------------------------------------------------
window.addEventListener('resize', () => {
if (!customized()) {
computeAutoWidths();
applyGrid();
}
});
applyPalette();
el.filter.value = state.filter;
buildKindFilter();
refreshFiltersButton();
el.group.setAttribute('aria-pressed', String(state.group));
el.body.tabIndex = 0;
computeAutoWidths();
applyGrid();
buildHead();
renderRows();
vscode.postMessage({ type: 'ready' });
})();