Mark write references in the editor view too
Kinds now live on the display ranges rather than in a side map, so the virtual document can paint reads and writes differently. - ResultLine.symbolRanges becomes DisplayRange[] carrying start, end, the source column and the kind. displayLine() classifies syntactically as it renders, so the write markers are correct on the very first paint and the document never waits on a language server. refineKinds() then applies documentHighlight results and re-decorates; only decorations change, never the text, and a superseded tab is left alone. - Writes get the theme's editor.wordHighlightStrong colours plus bold, which is what the editor itself uses for a write occurrence, so the two kinds are distinguishable in any theme. Reads keep the find-match highlight. - DisplayRange.sourceCol replaces the arithmetic that recovered a source column by shifting relative to the first reference on the line. Covered by a new test that walks every occurrence on a line holding two references to the same symbol and checks each one navigates to its own column. Also drops a stale assertion: an empty kind map used to mean "everything reads", and now means "use the syntactic classification", so buildRows() without a map is expected to report writes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,9 @@ Find All References results shown the way Visual Studio does it — in two views
|
||||
|
||||
**Editor view** (default) writes the results into a read-only virtual document in the *same language as your
|
||||
source file*, so your theme's grammar colors every line for free. File headers show the containing project
|
||||
(`.csproj`/`.fsproj`/`.vbproj`) and reference count; the referenced symbol is highlighted; line numbers are
|
||||
shown in the gutter.
|
||||
(`.csproj`/`.fsproj`/`.vbproj`) and reference count; line numbers are shown in the gutter. Read references are
|
||||
highlighted like search matches; **writes are bold** on the theme's stronger write-occurrence background, the
|
||||
same colour the editor itself uses for a write.
|
||||
|
||||
**Panel view** shows the same results as a table with resizable, sortable columns — Code, File, Line, Kind,
|
||||
Project and Containing member — grouped by file, with a filter box and a reads/writes filter. By default it
|
||||
@@ -76,7 +77,8 @@ the expected results stay deterministic.
|
||||
arguments), then `textDocument/documentHighlight` is asked per file and its `Read`/`Write` kinds override
|
||||
that wherever the server has an opinion. Servers that answer with plain `Text` highlights, or not at all,
|
||||
leave the syntactic answer standing — which is good for straightforward code and can be wrong for exotic
|
||||
expressions. Results spanning more than 60 files skip the server round-trip entirely.
|
||||
expressions. Results spanning more than 60 files skip the server round-trip entirely. The editor view paints
|
||||
the syntactic answer immediately and re-paints if the server disagrees, so it never waits on highlights.
|
||||
- Some language servers try to attach to every document of their language, including the virtual one, and may
|
||||
log a harmless error about an unknown URI scheme.
|
||||
|
||||
@@ -86,4 +88,4 @@ the expected results stay deterministic.
|
||||
result sets, and remembering column layout per workspace rather than per panel
|
||||
2. Semantic token overlay via `vscode.provideDocumentSemanticTokens`
|
||||
3. Filter by project / exclude tests
|
||||
4. ~~Read/write kind~~ — done for the panel; the editor view does not mark writes yet
|
||||
4. ~~Read/write kind~~ — done in both views
|
||||
|
||||
+57
-12
@@ -3,7 +3,8 @@ import * as path from 'path';
|
||||
import { PanelManager, Placement, ResultsView } from './panel';
|
||||
import {
|
||||
CODE_INDENT, Origin, ReferenceResults, RenderedDocument, ResultLine, SCHEME,
|
||||
findReferences, gather, invalidateSymbols, relativePath, renderDocument,
|
||||
applyKinds, findReferences, gather, invalidateSymbols, referenceKinds, relativePath,
|
||||
renderDocument, syntacticKinds,
|
||||
} from './references';
|
||||
|
||||
type View = 'document' | 'panel';
|
||||
@@ -93,6 +94,16 @@ const symbolDecoration = vscode.window.createTextEditorDecorationType({
|
||||
borderRadius: '2px',
|
||||
});
|
||||
|
||||
// Writes use the theme's "strong" word-highlight colour — the same one the editor uses
|
||||
// for a write occurrence — plus bold, so the two are told apart in any theme.
|
||||
const writeDecoration = vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: new vscode.ThemeColor('editor.wordHighlightStrongBackground'),
|
||||
border: '1px solid',
|
||||
borderColor: new vscode.ThemeColor('editor.wordHighlightStrongBorder'),
|
||||
borderRadius: '2px',
|
||||
fontWeight: 'bold',
|
||||
});
|
||||
|
||||
const headerDecoration = vscode.window.createTextEditorDecorationType({
|
||||
isWholeLine: true,
|
||||
fontWeight: 'bold',
|
||||
@@ -114,6 +125,7 @@ function applyDecorations(editor: vscode.TextEditor): void {
|
||||
|
||||
const numbers: vscode.DecorationOptions[] = [];
|
||||
const symbols: vscode.Range[] = [];
|
||||
const writes: vscode.DecorationOptions[] = [];
|
||||
const headers: vscode.Range[] = [];
|
||||
const titles: vscode.Range[] = [];
|
||||
|
||||
@@ -124,8 +136,13 @@ function applyDecorations(editor: vscode.TextEditor): void {
|
||||
range: new vscode.Range(i, 0, i, 0),
|
||||
renderOptions: { before: { contentText: String((line.sourceLine ?? 0) + 1) } },
|
||||
});
|
||||
for (const [start, end] of line.symbolRanges ?? []) {
|
||||
symbols.push(new vscode.Range(i, start, i, end));
|
||||
for (const range of line.symbolRanges ?? []) {
|
||||
const at = new vscode.Range(i, range.start, i, range.end);
|
||||
if (range.kind === 'write') {
|
||||
writes.push({ range: at, hoverMessage: 'Writes to the symbol' });
|
||||
} else {
|
||||
symbols.push(at);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -140,10 +157,37 @@ function applyDecorations(editor: vscode.TextEditor): void {
|
||||
|
||||
editor.setDecorations(lineNumberDecoration, numbers);
|
||||
editor.setDecorations(symbolDecoration, symbols);
|
||||
editor.setDecorations(writeDecoration, writes);
|
||||
editor.setDecorations(headerDecoration, headers);
|
||||
editor.setDecorations(titleDecoration, titles);
|
||||
}
|
||||
|
||||
function redecorate(uri: vscode.Uri): void {
|
||||
for (const editor of vscode.window.visibleTextEditors) {
|
||||
if (editor.document.uri.toString() === uri.toString()) {
|
||||
applyDecorations(editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades the syntactic read/write kinds with the language server's document
|
||||
* highlights. Only the decorations change, never the text, so this can land after the
|
||||
* results are already on screen.
|
||||
*/
|
||||
async function refineKinds(uri: vscode.Uri, stored: StoredDocument): Promise<void> {
|
||||
const kinds = await referenceKinds(stored.results);
|
||||
if (store.get(uri) !== stored) {
|
||||
return; // a newer search took this tab over
|
||||
}
|
||||
for (const line of stored.lines) {
|
||||
if (line.kind === 'code' && line.file && line.sourceLine !== undefined && line.symbolRanges) {
|
||||
applyKinds(line.file, line.sourceLine, line.symbolRanges, kinds);
|
||||
}
|
||||
}
|
||||
redecorate(uri);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Running a search
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -151,7 +195,9 @@ function applyDecorations(editor: vscode.TextEditor): void {
|
||||
/** Shows results in the virtual document view. Reuses `targetUri` when refreshing. */
|
||||
async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri): Promise<void> {
|
||||
const cfg = config();
|
||||
const rendered = renderDocument(results);
|
||||
// Syntactic kinds are free, so the write markers are right from the first paint;
|
||||
// refineKinds() upgrades them from the language server a moment later.
|
||||
const rendered = renderDocument(results, syntacticKinds(results));
|
||||
|
||||
const reuse = cfg.get<boolean>('reuseTab', true);
|
||||
const uri = targetUri ?? store.createUri(results.symbol, results.origin.uri, reuse);
|
||||
@@ -169,7 +215,8 @@ async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri):
|
||||
}
|
||||
}
|
||||
|
||||
store.set(uri, { ...rendered, results });
|
||||
const stored: StoredDocument = { ...rendered, results };
|
||||
store.set(uri, stored);
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
if (doc.languageId !== results.languageId) {
|
||||
@@ -197,6 +244,7 @@ async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri):
|
||||
editor.revealRange(new vscode.Range(0, 0, firstCode, 0), vscode.TextEditorRevealType.AtTop);
|
||||
}
|
||||
applyDecorations(editor);
|
||||
void refineKinds(uri, stored);
|
||||
}
|
||||
|
||||
async function showPanel(results: ReferenceResults, target?: ResultsView): Promise<void> {
|
||||
@@ -274,13 +322,9 @@ function targetLocation(document: vscode.TextDocument, position: vscode.Position
|
||||
}
|
||||
if (line.kind === 'code') {
|
||||
// Several references can share a line; if the cursor is inside one of them, jump to that one.
|
||||
// Displayed offsets differ from source offsets by a constant, so shifting relative to the first works.
|
||||
let col = line.sourceCol ?? 0;
|
||||
const ranges = line.symbolRanges ?? [];
|
||||
const hit = ranges.find(([s, e]) => position.character >= s && position.character <= e);
|
||||
if (hit && ranges.length > 1) {
|
||||
col += hit[0] - ranges[0][0];
|
||||
}
|
||||
const hit = (line.symbolRanges ?? []).find(
|
||||
r => position.character >= r.start && position.character <= r.end);
|
||||
const col = hit?.sourceCol ?? line.sourceCol ?? 0;
|
||||
return new vscode.Location(line.file, new vscode.Position(line.sourceLine ?? 0, col));
|
||||
}
|
||||
return undefined;
|
||||
@@ -432,6 +476,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
|
||||
lineNumberDecoration,
|
||||
symbolDecoration,
|
||||
writeDecoration,
|
||||
headerDecoration,
|
||||
titleDecoration,
|
||||
store.onDidChange,
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
RefKind, ReferenceResults, containingMembers, displayLine, refKey, referenceKinds, relativePath,
|
||||
RefKind, ReferenceResults, applyKinds, containingMembers, displayLine, referenceKinds, relativePath,
|
||||
} from './references';
|
||||
|
||||
/** Where the results table is shown. */
|
||||
@@ -48,12 +48,13 @@ export function buildRows(
|
||||
const dir = path.dirname(relative);
|
||||
for (const source of file.lines) {
|
||||
const { text, symbolRanges } = displayLine(source);
|
||||
applyKinds(file.uri, source.line, symbolRanges, kinds);
|
||||
const member = members.get(`${file.uri.toString()}|${source.line}`) ?? '';
|
||||
source.ranges.forEach((range, index) => {
|
||||
rows.push({
|
||||
id,
|
||||
code: text,
|
||||
hits: [symbolRanges[index]],
|
||||
hits: [[symbolRanges[index].start, symbolRanges[index].end]],
|
||||
file: path.basename(relative),
|
||||
dir: dir === '.' ? '' : dir,
|
||||
relPath: relative,
|
||||
@@ -61,7 +62,7 @@ export function buildRows(
|
||||
col: range.start.character + 1,
|
||||
project: file.project ?? '',
|
||||
member,
|
||||
kind: kinds.get(refKey(file.uri, source.line, range.start.character)) ?? 'read',
|
||||
kind: symbolRanges[index].kind,
|
||||
});
|
||||
locations.set(id, new vscode.Location(file.uri, range.start));
|
||||
id++;
|
||||
|
||||
+44
-7
@@ -43,6 +43,16 @@ export interface ReferenceResults {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One reference as it appears in displayed text. */
|
||||
export interface DisplayRange {
|
||||
/** Column range within the displayed text. */
|
||||
start: number;
|
||||
end: number;
|
||||
/** 0-based column in the source line, for navigation. */
|
||||
sourceCol: number;
|
||||
kind: RefKind;
|
||||
}
|
||||
|
||||
/** One displayed line in the virtual results document. */
|
||||
export interface ResultLine {
|
||||
kind: 'code' | 'header' | 'title' | 'blank';
|
||||
@@ -52,8 +62,8 @@ export interface ResultLine {
|
||||
sourceLine?: number;
|
||||
/** 0-based source column of the first reference on this line (code) */
|
||||
sourceCol?: number;
|
||||
/** Column ranges of the referenced symbol(s) within the displayed text (code) */
|
||||
symbolRanges?: [number, number][];
|
||||
/** The referenced symbol(s) on this line (code) */
|
||||
symbolRanges?: DisplayRange[];
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
@@ -226,23 +236,44 @@ export async function gather(
|
||||
*/
|
||||
export function displayLine(
|
||||
source: ReferenceLine, indent = '',
|
||||
): { text: string; symbolRanges: [number, number][] } {
|
||||
): { text: string; symbolRanges: DisplayRange[] } {
|
||||
const trimmed = source.text.trimStart();
|
||||
const removed = source.text.length - trimmed.length;
|
||||
const limit = indent.length + trimmed.length;
|
||||
|
||||
const symbolRanges: [number, number][] = [];
|
||||
const symbolRanges: DisplayRange[] = [];
|
||||
for (const range of source.ranges) {
|
||||
const start = Math.max(0, range.start.character - removed) + indent.length;
|
||||
const endChar = range.end.line === source.line ? range.end.character : source.text.length;
|
||||
const end = Math.max(start + 1, endChar - removed + indent.length);
|
||||
symbolRanges.push([Math.min(start, limit), Math.min(end, limit)]);
|
||||
symbolRanges.push({
|
||||
start: Math.min(start, limit),
|
||||
end: Math.min(end, limit),
|
||||
sourceCol: range.start.character,
|
||||
// Free, and correct for straightforward code; a language server's document
|
||||
// highlights can override it later without re-rendering anything.
|
||||
kind: syntacticKind(source.text, range),
|
||||
});
|
||||
}
|
||||
|
||||
return { text: indent + trimmed, symbolRanges };
|
||||
}
|
||||
|
||||
export function renderDocument(results: ReferenceResults): RenderedDocument {
|
||||
/** Overrides the syntactic kinds on `ranges` wherever `kinds` has an entry. */
|
||||
export function applyKinds(
|
||||
file: vscode.Uri, sourceLine: number, ranges: DisplayRange[], kinds: Map<string, RefKind>,
|
||||
): void {
|
||||
for (const range of ranges) {
|
||||
const kind = kinds.get(refKey(file, sourceLine, range.sourceCol));
|
||||
if (kind) {
|
||||
range.kind = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderDocument(
|
||||
results: ReferenceResults, kinds: Map<string, RefKind> = new Map(),
|
||||
): RenderedDocument {
|
||||
const prefix = commentPrefix(results.languageId);
|
||||
const out: string[] = [];
|
||||
const lines: ResultLine[] = [];
|
||||
@@ -265,6 +296,7 @@ export function renderDocument(results: ReferenceResults): RenderedDocument {
|
||||
|
||||
for (const source of file.lines) {
|
||||
const { text, symbolRanges } = displayLine(source, CODE_INDENT);
|
||||
applyKinds(file.uri, source.line, symbolRanges, kinds);
|
||||
push(text, {
|
||||
kind: 'code',
|
||||
file: file.uri,
|
||||
@@ -432,7 +464,7 @@ async function documentHighlights(
|
||||
* document highlights override it wherever they express an opinion — `Text` highlights
|
||||
* carry no kind, so those keep the syntactic answer.
|
||||
*/
|
||||
export async function referenceKinds(results: ReferenceResults): Promise<Map<string, RefKind>> {
|
||||
export function syntacticKinds(results: ReferenceResults): Map<string, RefKind> {
|
||||
const kinds = new Map<string, RefKind>();
|
||||
for (const file of results.files) {
|
||||
for (const line of file.lines) {
|
||||
@@ -442,6 +474,11 @@ export async function referenceKinds(results: ReferenceResults): Promise<Map<str
|
||||
}
|
||||
}
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
export async function referenceKinds(results: ReferenceResults): Promise<Map<string, RefKind>> {
|
||||
const kinds = syntacticKinds(results);
|
||||
|
||||
if (results.files.length > HIGHLIGHT_FILE_LIMIT) {
|
||||
return kinds;
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
import { buildRows } from '../../panel';
|
||||
import { gather, refKey, referenceKinds, syntacticKind } from '../../references';
|
||||
import { gather, refKey, referenceKinds, renderDocument, syntacticKind } from '../../references';
|
||||
|
||||
const KIND_FILE = path.join('Nerfed.Editor', 'Systems', 'EditorProfilerWindow.cs');
|
||||
const KIND_SYMBOL = 'selectedFrame';
|
||||
@@ -120,7 +120,54 @@ suite('Read / write kind from the language server', () => {
|
||||
assert.ok(writes >= 2 && reads >= 3, `expected reads and writes, got ${reads}/${writes}`);
|
||||
});
|
||||
|
||||
test('rows carry the kind, defaulting to read when unknown', async () => {
|
||||
test('the editor view marks writes on the right display ranges', async () => {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
assert.ok(folder);
|
||||
const uri = vscode.Uri.joinPath(folder.uri, ...KIND_FILE.split(path.sep));
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
|
||||
let position = new vscode.Position(0, 0);
|
||||
for (let i = 0; i < doc.lineCount; i++) {
|
||||
const at = doc.lineAt(i).text.indexOf(KIND_DECL);
|
||||
if (at >= 0) {
|
||||
position = new vscode.Position(i, doc.lineAt(i).text.indexOf(KIND_SYMBOL, at) + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const locations = await vscode.commands.executeCommand<vscode.Location[]>(
|
||||
'vscode.executeReferenceProvider', uri, position);
|
||||
const results = await gather(KIND_SYMBOL, 'csharp', { uri, position }, locations ?? [], true);
|
||||
|
||||
// Rendering with no kind map still classifies syntactically, so the markers are
|
||||
// correct on the first paint; the server pass only refines them.
|
||||
const bare = renderDocument(results);
|
||||
const refined = renderDocument(results, await referenceKinds(results));
|
||||
|
||||
for (const rendered of [bare, refined]) {
|
||||
const writes = new Set<number>();
|
||||
for (const line of rendered.lines) {
|
||||
if (line.kind !== 'code' || line.sourceLine === undefined) {
|
||||
continue;
|
||||
}
|
||||
const sourceText: string = doc.lineAt(line.sourceLine).text;
|
||||
const displayed: string = rendered.content.split('\n')[rendered.lines.indexOf(line)];
|
||||
for (const range of line.symbolRanges ?? []) {
|
||||
// The recorded source column must actually be the symbol.
|
||||
assert.ok(sourceText.startsWith(KIND_SYMBOL, range.sourceCol),
|
||||
`line ${line.sourceLine + 1} col ${range.sourceCol} is not ${KIND_SYMBOL}`);
|
||||
// And the display range must cover the symbol in the rendered text.
|
||||
assert.strictEqual(displayed.slice(range.start, range.end), KIND_SYMBOL);
|
||||
if (range.kind === 'write') {
|
||||
writes.add(line.sourceLine + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual([...writes].sort((a, b) => a - b), [41, 43],
|
||||
'expected the assignment and the ref argument to be the only writes');
|
||||
}
|
||||
});
|
||||
|
||||
test('rows carry the kind, falling back to the syntactic one', async () => {
|
||||
const folder = vscode.workspace.workspaceFolders?.[0];
|
||||
assert.ok(folder);
|
||||
const uri = vscode.Uri.joinPath(folder.uri, ...KIND_FILE.split(path.sep));
|
||||
@@ -145,8 +192,12 @@ suite('Read / write kind from the language server', () => {
|
||||
'every row needs a read/write kind');
|
||||
assert.ok(rows.some(r => r.kind === 'write'), 'no write rows');
|
||||
|
||||
// With no kind map at all, rows still render as reads rather than blank.
|
||||
// With no kind map at all — a language server that serves no document
|
||||
// highlights — rows still carry the syntactic classification, not a blank
|
||||
// column or a uniform "read".
|
||||
const { rows: bare } = buildRows(results, new Map());
|
||||
assert.ok(bare.every(r => r.kind === 'read'));
|
||||
assert.deepStrictEqual(bare.map(r => r.kind), rows.map(r => r.kind),
|
||||
'the syntactic fallback should already agree with the server here');
|
||||
assert.ok(bare.some(r => r.kind === 'write'), 'no write rows without a kind map');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +209,39 @@ suite('Colored References - real C# solution', () => {
|
||||
assert.deepStrictEqual(failures, [], failures.join('\n'));
|
||||
});
|
||||
|
||||
test('each occurrence on a shared line navigates to its own column', async () => {
|
||||
const shared: number[] = [];
|
||||
for (const line of codeLineNumbers(doc)) {
|
||||
const text = doc.lineAt(line).text;
|
||||
if (text.split(TARGET_SYMBOL).length - 1 > 1) {
|
||||
shared.push(line);
|
||||
}
|
||||
}
|
||||
assert.ok(shared.length > 0,
|
||||
`no result line holds two references to ${TARGET_SYMBOL}; this test needs one`);
|
||||
|
||||
const failures: string[] = [];
|
||||
for (const line of shared) {
|
||||
const text = doc.lineAt(line).text;
|
||||
for (let at = text.indexOf(TARGET_SYMBOL); at >= 0;
|
||||
at = text.indexOf(TARGET_SYMBOL, at + 1)) {
|
||||
const target = await definitionAt(doc, line, at + 1);
|
||||
if (!target) {
|
||||
failures.push(`line ${line} col ${at}: no target`);
|
||||
continue;
|
||||
}
|
||||
const source = await vscode.workspace.openTextDocument(target.uri);
|
||||
const landed = source.lineAt(target.range.start.line).text
|
||||
.slice(target.range.start.character);
|
||||
if (!landed.startsWith(TARGET_SYMBOL)) {
|
||||
failures.push(`line ${line} col ${at} landed on ` +
|
||||
`${JSON.stringify(landed.slice(0, 20))}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user