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>
875 lines
33 KiB
JavaScript
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' });
|
|
})();
|