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>
This commit is contained in:
max
2026-09-07 16:17:08 +02:00
co-authored by Claude Opus 5
commit 2398b6b2ce
20 changed files with 4253 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { runTests } from '@vscode/test-electron';
/**
* Launches a real VS Code with this extension plus the user's installed language
* servers (DotRush / C# Dev Kit) and runs the integration suite against a C# solution.
*
* The solution folder is taken from COLORED_REFS_TEST_FOLDER (default: the MyGame solution).
* DotRush's `projectOrSolutionFiles` is window-scoped, so we open a generated
* .code-workspace that pins it to the solution in that folder instead of editing
* the target repo's own .vscode/settings.json.
*/
async function main(): Promise<void> {
const extensionDevelopmentPath = path.resolve(__dirname, '../../');
const extensionTestsPath = path.resolve(__dirname, './suite/index');
const folder = path.normalize(process.env.COLORED_REFS_TEST_FOLDER ?? 'D:/Projects/MyGame');
if (!fs.existsSync(folder)) {
throw new Error(`Test folder does not exist: ${folder}`);
}
const sln = fs.readdirSync(folder).find(f => f.toLowerCase().endsWith('.sln'));
if (!sln) {
throw new Error(`No .sln found in ${folder}`);
}
const workspaceFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'colored-refs-')), 'target.code-workspace');
fs.writeFileSync(workspaceFile, JSON.stringify({
folders: [{ path: folder }],
settings: {
'dotrush.roslyn.projectOrSolutionFiles': [path.join(folder, sln)],
'dotrush.roslyn.restoreProjectsBeforeLoading': false,
'dotnet.server.useOmnisharp': false,
'security.workspace.trust.enabled': false,
'telemetry.telemetryLevel': 'off',
},
}, null, 2));
console.log(`[runTest] workspace: ${workspaceFile}`);
console.log(`[runTest] solution: ${path.join(folder, sln)}`);
// Reuse the real extensions dir so DotRush / C# Dev Kit are available.
const extensionsDir = process.env.COLORED_REFS_EXTENSIONS_DIR ??
path.join(os.homedir(), '.vscode', 'extensions');
await runTests({
extensionDevelopmentPath,
extensionTestsPath,
launchArgs: [
workspaceFile,
'--extensions-dir', extensionsDir,
// Isolate DotRush: C# Dev Kit / OmniSharp would answer the same
// reference request and make the expected result nondeterministic.
'--disable-extension', 'ms-dotnettools.csdevkit',
'--disable-extension', 'ms-dotnettools.csharp',
'--disable-workspace-trust',
'--skip-welcome',
'--skip-release-notes',
],
});
}
main().catch(err => {
console.error('Integration tests failed:', err);
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
import * as path from 'path';
import Mocha = require('mocha');
export function run(): Promise<void> {
const mocha = new Mocha({
ui: 'tdd',
color: true,
// DotRush has to restore + load the whole solution before it answers.
timeout: 5 * 60 * 1000,
slow: 30 * 1000,
});
mocha.addFile(path.resolve(__dirname, 'references.test.js'));
mocha.addFile(path.resolve(__dirname, 'panel.test.js'));
return new Promise((resolve, reject) => {
try {
mocha.run((failures: number) => {
if (failures > 0) {
reject(new Error(`${failures} test(s) failed.`));
} else {
resolve();
}
});
} catch (err) {
reject(err);
}
});
}
+149
View File
@@ -0,0 +1,149 @@
import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
import { buildRows } from '../../panel';
import { ReferenceResults, containingMembers, gather } from '../../references';
const PANEL_VIEW_TYPE = 'coloredReferences.results';
const TARGET_FILE = path.join('Nerfed.Runtime', 'Profiler.cs');
const TARGET_SYMBOL = 'Profiler';
const TARGET_DECL = 'public static class Profiler';
const SECOND_FILE = path.join('Nerfed.Runtime', 'Log.cs');
const SECOND_SYMBOL = 'LogInternal';
const SECOND_DECL = 'private static void LogInternal';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
function root(): vscode.Uri {
const folder = vscode.workspace.workspaceFolders?.[0];
assert.ok(folder, 'no workspace folder open');
return folder.uri;
}
function symbolPosition(doc: vscode.TextDocument, declaration: string, symbol: string): vscode.Position {
for (let i = 0; i < doc.lineCount; i++) {
const text = doc.lineAt(i).text;
const at = text.indexOf(declaration);
if (at >= 0) {
return new vscode.Position(i, text.indexOf(symbol, at) + 1);
}
}
throw new Error(`declaration '${declaration}' not found`);
}
function panelTabs(): vscode.Tab[] {
return vscode.window.tabGroups.all.flatMap(g => g.tabs).filter(tab => {
const input = tab.input as { viewType?: string } | undefined;
return typeof input?.viewType === 'string' && input.viewType.includes(PANEL_VIEW_TYPE);
});
}
/** Puts the cursor on a symbol and returns the gathered results for it. */
async function search(relativeFile: string, declaration: string, symbol: string): Promise<{
results: ReferenceResults; position: vscode.Position; uri: vscode.Uri;
}> {
const uri = vscode.Uri.joinPath(root(), ...relativeFile.split(path.sep));
const source = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(source, { preview: false });
const position = symbolPosition(source, declaration, symbol);
editor.selection = new vscode.Selection(position, position);
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
'vscode.executeReferenceProvider', uri, position);
assert.ok(locations && locations.length > 1, `no references for ${symbol}`);
const results = await gather(symbol, 'csharp', { uri, position }, locations, true);
return { results, position, uri };
}
suite('Results panel', () => {
let results: ReferenceResults;
suiteSetup(async () => {
const ext = vscode.extensions.getExtension('local.colored-references');
assert.ok(ext);
await ext.activate();
results = (await search(TARGET_FILE, TARGET_DECL, TARGET_SYMBOL)).results;
});
test('one row per reference, each highlighting its own occurrence', async () => {
const members = await containingMembers(results);
const { rows, locations } = buildRows(results, members);
assert.strictEqual(rows.length, results.total, 'row count should equal the reference count');
assert.strictEqual(locations.size, rows.length, 'every row needs a navigation target');
const wrong: string[] = [];
for (const row of rows) {
const [start, end] = row.hits[0];
const highlighted = row.code.slice(start, end);
if (highlighted !== TARGET_SYMBOL) {
wrong.push(`${row.file}:${row.line}:${row.col} highlights ` +
`${JSON.stringify(highlighted)} in ${JSON.stringify(row.code)}`);
}
assert.ok(row.code === row.code.trimStart(), 'code cells should not carry indentation');
}
assert.deepStrictEqual(wrong, [], wrong.join('\n'));
});
test('rows carry the project and a relative path split into name and directory', async () => {
const { rows } = buildRows(results, new Map());
for (const row of rows) {
assert.ok(row.project.length > 0, `no project for ${row.relPath}`);
assert.strictEqual(row.relPath, row.dir ? `${row.dir}/${row.file}` : row.file);
assert.ok(row.line >= 1 && row.col >= 1, 'line and column are 1-based');
}
});
test('containing member resolves to the enclosing method, nested types included', async () => {
const members = await containingMembers(results);
const engine = results.files.find(f => f.uri.fsPath.endsWith('Engine.cs'));
assert.ok(engine, 'Engine.cs is missing from the results');
const memberFor = (line: number) => members.get(`${engine.uri.toString()}|${line - 1}`) ?? '';
// Engine.cs:48 is inside Engine.Run; :81 is inside the nested NerfedGame.Draw.
// Roslyn appends the parameter list to method names, so match the path prefix only.
assert.ok(memberFor(48).startsWith('Engine.Run'), `line 48: ${memberFor(48)}`);
assert.ok(memberFor(81).startsWith('Engine.NerfedGame.Draw'), `line 81: ${memberFor(81)}`);
const resolved = [...members.values()].filter(m => m.length > 0).length;
assert.ok(resolved >= members.size - 1,
`only ${resolved} of ${members.size} lines resolved a containing member`);
});
test('findInPanel opens a single webview panel titled after the symbol', async () => {
const before = panelTabs().length;
assert.strictEqual(before, 0, 'a panel was already open');
await vscode.commands.executeCommand('coloredReferences.findInPanel');
await sleep(1500);
const tabs = panelTabs();
assert.strictEqual(tabs.length, 1, 'expected exactly one results panel');
assert.strictEqual(tabs[0].label, `References to ${TARGET_SYMBOL}`);
});
test('a second search reuses the panel and re-titles it', async () => {
await search(SECOND_FILE, SECOND_DECL, SECOND_SYMBOL);
await vscode.commands.executeCommand('coloredReferences.findInPanel');
await sleep(1500);
const tabs = panelTabs();
assert.strictEqual(tabs.length, 1, 'reuseTab is on but a second panel was opened');
assert.strictEqual(tabs[0].label, `References to ${SECOND_SYMBOL}`);
});
test('toggleView moves the current results into the editor view', async () => {
await vscode.commands.executeCommand('coloredReferences.toggleView');
await sleep(1500);
const editorTabs = vscode.window.tabGroups.all.flatMap(g => g.tabs).filter(tab => {
const input = tab.input as { uri?: vscode.Uri } | undefined;
return input?.uri?.scheme === 'colored-refs';
});
assert.strictEqual(editorTabs.length, 1, 'expected the results to open in an editor tab');
assert.ok(editorTabs[0].label.startsWith(`References to ${SECOND_SYMBOL}`),
`unexpected tab label: ${editorTabs[0].label}`);
});
});
+258
View File
@@ -0,0 +1,258 @@
import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
const SCHEME = 'colored-refs';
const TARGET_FILE = path.join('Nerfed.Runtime', 'Profiler.cs');
const TARGET_SYMBOL = 'Profiler';
const TARGET_DECL = 'public static class Profiler';
const SECOND_FILE = path.join('Nerfed.Runtime', 'Log.cs');
const SECOND_SYMBOL = 'LogInternal';
const SECOND_DECL = 'private static void LogInternal';
const CODE_INDENT = ' ';
function root(): vscode.Uri {
const folder = vscode.workspace.workspaceFolders?.[0];
assert.ok(folder, 'no workspace folder open');
return folder.uri;
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
/** Position of `symbol` inside the line containing `declaration`. */
function symbolPosition(doc: vscode.TextDocument, declaration: string, symbol: string): vscode.Position {
for (let i = 0; i < doc.lineCount; i++) {
const text = doc.lineAt(i).text;
const at = text.indexOf(declaration);
if (at >= 0) {
const col = text.indexOf(symbol, at);
assert.ok(col >= 0, `'${symbol}' not found in '${text}'`);
return new vscode.Position(i, col + 1);
}
}
throw new Error(`declaration '${declaration}' not found in ${doc.uri.fsPath}`);
}
/** Waits for a language server to answer the reference request. */
async function waitForReferences(
uri: vscode.Uri, pos: vscode.Position, minimum: number, timeoutMs: number,
): Promise<vscode.Location[]> {
const deadline = Date.now() + timeoutMs;
let last = 0;
while (Date.now() < deadline) {
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
'vscode.executeReferenceProvider', uri, pos);
last = locations?.length ?? 0;
if (last >= minimum) {
return locations;
}
console.log(`[test] waiting for references (${last}/${minimum}), ` +
`${Math.round((deadline - Date.now()) / 1000)}s left`);
await sleep(3000);
}
throw new Error(`language server returned only ${last} references after ${timeoutMs}ms - ` +
`is DotRush loaded and the solution restored?`);
}
function dedupe(locations: vscode.Location[]): vscode.Location[] {
const seen = new Set<string>();
return locations.filter(l => {
const key = `${l.uri.toString()}|${l.range.start.line}|${l.range.start.character}|` +
`${l.range.end.line}|${l.range.end.character}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function coloredTabs(): vscode.Tab[] {
return vscode.window.tabGroups.all.flatMap(g => g.tabs).filter(t => {
const input = t.input as { uri?: vscode.Uri } | undefined;
return input?.uri?.scheme === SCHEME;
});
}
async function runFind(relativeFile: string, declaration: string, symbol: string) {
const uri = vscode.Uri.joinPath(root(), ...relativeFile.split(path.sep));
const source = await vscode.workspace.openTextDocument(uri);
const editor = await vscode.window.showTextDocument(source, { preview: false });
const pos = symbolPosition(source, declaration, symbol);
editor.selection = new vscode.Selection(pos, pos);
const locations = dedupe(await waitForReferences(uri, pos, 2, 4 * 60 * 1000));
await vscode.commands.executeCommand('coloredReferences.find');
await sleep(500);
const active = vscode.window.activeTextEditor;
assert.ok(active, 'no active editor after find');
return { locations, editor: active, doc: active.document, sourceUri: uri };
}
/** Display lines that render a source line (indented, not a comment). */
function codeLineNumbers(doc: vscode.TextDocument): number[] {
const out: number[] = [];
for (let i = 0; i < doc.lineCount; i++) {
const text = doc.lineAt(i).text;
if (text.startsWith(CODE_INDENT) && text.trim().length > 0) {
out.push(i);
}
}
return out;
}
function headerLineNumbers(doc: vscode.TextDocument): number[] {
const out: number[] = [];
for (let i = 2; i < doc.lineCount; i++) {
if (doc.lineAt(i).text.startsWith('// ')) {
out.push(i);
}
}
return out;
}
async function definitionAt(doc: vscode.TextDocument, line: number, character: number) {
const result = await vscode.commands.executeCommand<vscode.Location[] | vscode.LocationLink[]>(
'vscode.executeDefinitionProvider', doc.uri, new vscode.Position(line, character));
if (!result || result.length === 0) {
return undefined;
}
const first = result[0] as vscode.Location & vscode.LocationLink;
return {
uri: first.uri ?? first.targetUri,
range: first.range ?? first.targetRange,
};
}
suite('Colored References - real C# solution', () => {
let locations: vscode.Location[];
let doc: vscode.TextDocument;
suiteSetup(async () => {
const ext = vscode.extensions.getExtension('local.colored-references');
assert.ok(ext, 'extension not found');
await ext.activate();
console.log(`[test] workspace root: ${root().fsPath}`);
const result = await runFind(TARGET_FILE, TARGET_DECL, TARGET_SYMBOL);
locations = result.locations;
doc = result.doc;
console.log(`[test] ${locations.length} unique references`);
console.log('[test] ---- results document ----');
console.log(doc.getText());
console.log('[test] ---------------------------');
});
test('opens a virtual results document', () => {
assert.strictEqual(doc.uri.scheme, SCHEME);
assert.ok(doc.getText().length > 0, 'results document is empty');
});
test('results document keeps the source language for coloring', () => {
assert.strictEqual(doc.languageId, 'csharp');
});
test('title line reports the deduplicated reference and file count', () => {
const title = doc.lineAt(0).text;
const match = /^\/\/ (\d+) references? to '(.+)' in (\d+) files?$/.exec(title);
assert.ok(match, `unexpected title line: ${JSON.stringify(title)}`);
assert.strictEqual(match[2], TARGET_SYMBOL);
assert.strictEqual(Number(match[1]), locations.length,
'reference count in the title does not match the deduplicated locations');
const files = new Set(locations.map(l => l.uri.toString())).size;
assert.strictEqual(Number(match[3]), files, 'file count in the title is wrong');
assert.strictEqual(headerLineNumbers(doc).length, files, 'wrong number of file headers');
});
test('renders one line per referenced source line', () => {
const rendered = codeLineNumbers(doc).length;
const sourceLines = new Set(locations.map(l => `${l.uri.toString()}|${l.range.start.line}`)).size;
assert.strictEqual(rendered, sourceLines);
});
test('every rendered line maps back to the matching source line', async () => {
const failures: string[] = [];
for (const line of codeLineNumbers(doc)) {
const displayed = doc.lineAt(line).text.trim();
const target = await definitionAt(doc, line, CODE_INDENT.length + 1);
if (!target) {
failures.push(`line ${line}: no definition target for ${JSON.stringify(displayed)}`);
continue;
}
const source = await vscode.workspace.openTextDocument(target.uri);
const sourceText = source.lineAt(target.range.start.line).text.trim();
if (sourceText !== displayed) {
failures.push(`line ${line}: shows ${JSON.stringify(displayed)} but points at ` +
`${path.basename(target.uri.fsPath)}:${target.range.start.line + 1} ` +
`which is ${JSON.stringify(sourceText)}`);
}
}
assert.deepStrictEqual(failures, [], failures.join('\n'));
});
test('the column the cursor lands on is inside the referenced symbol', async () => {
const failures: string[] = [];
for (const line of codeLineNumbers(doc)) {
const target = await definitionAt(doc, line, CODE_INDENT.length + 1);
if (!target) {
continue;
}
const source = await vscode.workspace.openTextDocument(target.uri);
const word = source.getWordRangeAtPosition(target.range.start);
const at = word ? source.getText(word) : '<none>';
if (!at.includes(TARGET_SYMBOL) && !TARGET_SYMBOL.includes(at)) {
failures.push(`${path.basename(target.uri.fsPath)}:${target.range.start.line + 1}:` +
`${target.range.start.character + 1} lands on ${JSON.stringify(at)}`);
}
}
assert.deepStrictEqual(failures, [], failures.join('\n'));
});
test('file headers navigate to the top of the file and show the project', async () => {
const headers = headerLineNumbers(doc);
assert.ok(headers.length > 0);
let withProject = 0;
for (const line of headers) {
const text = doc.lineAt(line).text;
if (/^\/\/ \[[^\]]+\]/.test(text)) {
withProject++;
}
const target = await definitionAt(doc, line, 4);
assert.ok(target, `header ${JSON.stringify(text)} has no navigation target`);
assert.strictEqual(target.range.start.line, 0, 'header should point at line 1');
assert.ok(text.includes(path.basename(target.uri.fsPath)),
`header ${JSON.stringify(text)} does not name ${target.uri.fsPath}`);
}
assert.strictEqual(withProject, headers.length, 'some headers are missing the [Project] label');
});
test('hover on a result shows the source location', async () => {
const line = codeLineNumbers(doc)[0];
const hovers = await vscode.commands.executeCommand<vscode.Hover[]>(
'vscode.executeHoverProvider', doc.uri, new vscode.Position(line, CODE_INDENT.length + 1));
assert.ok(hovers && hovers.length > 0, 'no hover');
});
test('refresh re-runs the search in place', async () => {
const before = doc.getText();
const tabsBefore = coloredTabs().length;
await vscode.window.showTextDocument(doc, { preview: false });
await vscode.commands.executeCommand('coloredReferences.refresh');
await sleep(1500);
const after = doc.getText();
assert.ok(after.length > 0, 'results document became empty after refresh');
assert.strictEqual(after, before, 'refresh changed the results');
assert.strictEqual(coloredTabs().length, tabsBefore, 'refresh opened another tab');
});
test('reuseTab keeps a single results tab across different symbols', async () => {
assert.strictEqual(vscode.workspace.getConfiguration('coloredReferences').get('reuseTab'), true);
const second = await runFind(SECOND_FILE, SECOND_DECL, SECOND_SYMBOL);
assert.strictEqual(second.doc.uri.scheme, SCHEME);
assert.ok(second.doc.getText().includes(SECOND_SYMBOL),
'second search did not render the new symbol');
assert.strictEqual(coloredTabs().length, 1,
'reuseTab is on but a second results tab was opened');
});
});