Files
vs-code-colored-references/media/panel.js
T
maxandClaude Opus 5 2398b6b2ce Colored References: fix broken navigation, add sortable results panel
The extension shows Find All References results in a syntax-highlighted
virtual document. Two things were wrong and roadmap item 1 was missing.

Fixes, all reproduced against a real C# solution with DotRush:

- Navigation and hover were dead on every result. setTextDocumentLanguage()
  closes and re-opens the document under the same URI, which fired
  onDidCloseTextDocument and dropped the results from the store. The text
  still rendered because VS Code caches the model, so the pane looked
  correct while Enter / F12 / Ctrl+Click / hover all returned nothing.
  The virtual URI now carries the source file's extension so VS Code infers
  the language without recreating the document, and a close only discards
  results once no tab or document for that URI remains.
- reuseTab never reused: the symbol name is part of the URI, so every new
  symbol opened another tab. The previous results tab is now closed and its
  group taken over.
- The reference count in the title double-counted when two language servers
  answer the same request (DotRush plus C# Dev Kit). It now comes from the
  de-duplicated set.
- A results tab hidden behind another editor was duplicated into a new group
  instead of being revealed.
- Decorations are reapplied on active-editor change; a results tab restored
  from a previous window is closed instead of left as a dead empty document;
  searching with no symbol under the cursor no longer searches for "symbol".

Roadmap item 1 - webview results panel:

- Shared gathering, grouping and rendering moved to references.ts so both
  views work from the same data.
- panel.ts plus media/ render the results as a table with resizable,
  sortable Code / File / Line / Project / Containing member columns,
  collapsible per-file groups, a filter box, keyboard navigation, and
  single-click preview versus Enter to jump. Column widths default to a
  share of the panel width until dragged.
- Containing member comes from executeDocumentSymbolProvider, nested types
  included.
- coloredReferences.view selects the default view; toggleView switches the
  current results between the two.

Adds an integration suite that launches a real VS Code against a C#
solution and asserts on the rendered output, including that every displayed
line maps back to the source line it claims.

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

634 lines
23 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.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<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;
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' });
})();