Files
vs-code-colored-references/media/panel.js
T
maxandClaude Opus 5 9b2dbc4522 Show read/write kind and let the panel filter on it
The reference request carries no read/write information, so derive it:

- Classify every reference syntactically first — assignment and compound
  assignment, ++/--, and ref/out arguments are writes, everything else is a
  read. The suffix test only looks past the end of the reference, so the `=`
  in `previous = x` does not make the read of `x` look like a write.
- Then ask textDocument/documentHighlight per file, whose Read/Write kinds
  override the syntactic answer. Plain Text highlights carry no kind and
  leave it standing, so servers without the feature still get a sensible
  column. Files have to be opened as text documents for the server to
  answer, so results spanning more than 60 files skip this step.

In the panel: a sortable Kind column with read/write badges, All / Reads /
Writes buttons, the kind included in the text filter, and a write count in
the summary line. The kind filter is persisted with the rest of the view
state. Line and Kind columns carry minimum widths that fit their own
headers, which Line previously did not.

Verified against DotRush on a field that is both read and written: the
assignment and the `ref` argument classify as writes, the subscript, the
comparison and the right-hand-side use as reads.

The editor view does not mark writes yet.

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

677 lines
25 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. 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 (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));
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' });
})();