Files
vs-code-colored-references/media/panel.js
T
maxandClaude Opus 5 3e8ece8ed7 Classify the panel's code column with real token types
The panel guessed that any capitalised identifier was a type, so
`Profiler.Frames.Count` came out as three type-coloured names where the
editor shows a class and two members. The semantic overlay already fetches
per-file tokens, so feed the same data to the webview.

- codeSpans() returns the server's token spans per referenced line in the
  row's own trimmed coordinates; rows carry them and the webview colours from
  them, mapping token type names to its palette. Fields, properties, events
  and methods share the member colour, as they do in the stock themes.
- The regex tokenizer stays as the fallback for servers that serve no
  semantic tokens, and coloredReferences.semanticTokens now gates both views
  rather than just the editor.

Colours are still the approximated Dark+/Light+ palette — a webview is not
given the theme's token colours — so only the editor view can be theme-exact.
The README says so instead of promising this would fix it.

Two things the new tests establish, both assumptions the code was already
making: all files in a result share the origin's legend, so decoding tokens
from every file against one legend is sound; and all 237 relocated tokens in
the Profiler search report the same type name as their source token.

Also relaxes an over-specific assertion: DotRush calls Profiler.Frames a
field, not a property. Either way it is a member, which is what matters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:32:32 +02:00

727 lines
27 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}} */
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',
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')),
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 (!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();
});
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' && 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;
buildKindFilter();
el.group.setAttribute('aria-pressed', String(state.group));
el.body.tabIndex = 0;
computeAutoWidths();
applyGrid();
buildHead();
renderRows();
vscode.postMessage({ type: 'ready' });
})();