// @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.40, min: 120, align: 'left' }, { key: 'file', label: 'File', share: 0.20, min: 80, align: 'left' }, { key: 'line', label: 'Line', share: 0.06, min: 44, align: 'right' }, { key: 'project', label: 'Project', share: 0.14, min: 60, align: 'left' }, { key: 'member', label: 'Containing member', share: 0.20, min: 80, align: 'left' }, ]; /** @type {{symbol: string, languageId: string, rows: any[], fileCount: number}} */ 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 || '', 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')), 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. Roadmap item 2 (semantic tokens) can replace this. const PALETTES = { dark: { 'fg-default': '#d4d4d4', comment: '#6a9955', string: '#ce9178', keyword: '#569cd6', control: '#c586c0', number: '#b5cea8', type: '#4ec9b0', call: '#dcdcaa', }, light: { 'fg-default': '#000000', comment: '#008000', string: '#a31515', keyword: '#0000ff', control: '#af00db', number: '#098658', type: '#267f99', call: '#795e26', }, contrast: { 'fg-default': '#ffffff', comment: '#7ca668', string: '#ce9178', keyword: '#569cd6', control: '#c586c0', number: '#b5cea8', type: '#4ec9b0', call: '#dcdcaa', }, }; 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; } /** * Renders a code line: tokenized, with the referenced symbol boxed. * @param {string} text * @param {[number, number][]} hits */ function renderCode(text, hits) { 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); } let offset = 0; for (const [cls, piece] of tokenize(text)) { 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 (!needle) { return true; } return (row.code + ' ' + row.relPath + ' ' + row.project + ' ' + row.member) .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)); 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))); 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} */ 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; 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'}`)); } // ----------------------------------------------------------------------- // 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(); }); 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' && 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; // 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; el.group.setAttribute('aria-pressed', String(state.group)); el.body.tabIndex = 0; computeAutoWidths(); applyGrid(); buildHead(); renderRows(); vscode.postMessage({ type: 'ready' }); })();