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:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
out
|
||||
*.vsix
|
||||
.vscode-test
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/out/**/*.js"],
|
||||
"preLaunchTask": "npm: compile"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "compile",
|
||||
"problemMatcher": "$tsc",
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
src/**
|
||||
out/test/**
|
||||
tsconfig.json
|
||||
**/*.map
|
||||
.gitignore
|
||||
package-lock.json
|
||||
@@ -0,0 +1,15 @@
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
|
||||
to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
||||
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Colored References
|
||||
|
||||
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.
|
||||
|
||||
**Panel view** shows the same results as a table with resizable, sortable columns — Code, File, Line, Project
|
||||
and Containing member — grouped by file, with a filter box.
|
||||
|
||||
Works with any language server that implements references: DotRush, C# Dev Kit, OmniSharp, TypeScript, Rust, Go, ...
|
||||
|
||||
## Usage
|
||||
|
||||
| Action | How |
|
||||
| --- | --- |
|
||||
| Find references | `Ctrl+Alt+F12` (`Cmd+Alt+F12` on mac), right-click → *Find All References (Colored)*, or the command palette |
|
||||
| Pick a view explicitly | *References: Find All References in Colored Editor* / *… in Results Panel* |
|
||||
| Switch the current results to the other view | `Ctrl+Alt+Shift+F12`, or the split icon in the tab bar |
|
||||
| Go to a reference | `Enter`, `F12`, or `Ctrl+Click` on a result line. `Enter` on a file header opens the file. |
|
||||
| Re-run the search | `F5`, or the refresh icon in the tab bar / panel toolbar |
|
||||
|
||||
In the panel, a single click previews a reference without leaving the panel, `Enter` or a double-click jumps to
|
||||
it, arrow keys walk the list, `Ctrl+F` focuses the filter, and clicking a column header sorts by it. Drag a
|
||||
column edge to resize it; double-click the edge to reset it.
|
||||
|
||||
## Settings
|
||||
|
||||
- `coloredReferences.view` — which view `Find All References (Colored)` opens: `document` (default) or `panel`
|
||||
- `coloredReferences.openBeside` — open results beside the current editor (default `true`)
|
||||
- `coloredReferences.showProject` — show the containing project in file headers (default `true`)
|
||||
- `coloredReferences.reuseTab` — reuse one results tab/panel instead of opening a new one per search (default `true`)
|
||||
|
||||
## Install
|
||||
|
||||
Either install the `.vsix` (Extensions view → `…` → *Install from VSIX…*) or open this folder in VS Code,
|
||||
run `npm install`, and press `F5` to launch an Extension Development Host.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm test` launches a real VS Code against a C# solution, waits for the language server to answer, and asserts
|
||||
on the rendered results — that every displayed line maps back to the source line it claims, that navigation and
|
||||
hover work, that the panel's rows highlight the right occurrence, and that the containing member resolves.
|
||||
|
||||
Point it at your own solution with `COLORED_REFS_TEST_FOLDER`:
|
||||
|
||||
```bash
|
||||
COLORED_REFS_TEST_FOLDER=/path/to/solution npm test
|
||||
```
|
||||
|
||||
The run opens a generated `.code-workspace` that pins DotRush to the solution found in that folder, so your
|
||||
repository's own `.vscode/settings.json` is left alone. C# Dev Kit and OmniSharp are disabled for the run so
|
||||
the expected results stay deterministic.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Coloring in the editor view is TextMate-only (no semantic tokens), since language servers only serve semantic
|
||||
tokens for real files. Types and identifiers therefore look like they do in a freshly opened file before the
|
||||
server has analyzed it.
|
||||
- The panel's code column cannot use your theme's token colors: webviews are not given them as CSS variables.
|
||||
It approximates the stock Dark+/Light+ hues instead. Semantic tokens (roadmap 2) would replace this.
|
||||
- Read/write kind is not shown yet — no language server reports it through the standard reference request.
|
||||
- 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.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. ~~Webview panel with resizable, sortable columns~~ — done; missing: virtualized rendering for very large
|
||||
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. Optional DotRush fast path for containing member + read/write kind
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
/* Colored References — webview results panel */
|
||||
|
||||
:root {
|
||||
--row-height: 22px;
|
||||
--header-height: 24px;
|
||||
--grid: var(--cr-grid, 40% 20% 56px 14% 20%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* --- toolbar ------------------------------------------------------------ */
|
||||
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#summary {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#summary b {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
#filter {
|
||||
width: 200px;
|
||||
padding: 2px 6px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
#filter:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
#filter::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||
background: var(--vscode-button-secondaryBackground, transparent);
|
||||
border: 1px solid var(--vscode-contrastBorder, transparent);
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground));
|
||||
}
|
||||
|
||||
.toolbar-button[aria-pressed='true'] {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
}
|
||||
|
||||
.toolbar-button:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* --- table -------------------------------------------------------------- */
|
||||
|
||||
#head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
grid-template-columns: var(--grid);
|
||||
height: var(--header-height);
|
||||
background: var(--vscode-keybindingTable-headerBackground, var(--vscode-editor-background));
|
||||
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-editorWidget-border));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#head .th {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#head .th:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
#head .th .label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#head .th .arrow {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
#head .th[data-sorted] .arrow {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#head .th:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Column resize grip: sits on the right edge of every header cell. */
|
||||
#head .grip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 7px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#head .grip:hover,
|
||||
body.resizing #head .grip {
|
||||
background: var(--vscode-sash-hoverBorder);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
body.resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#table {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
#body {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--grid);
|
||||
height: var(--row-height);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.row.selected {
|
||||
color: var(--vscode-list-inactiveSelectionForeground);
|
||||
background: var(--vscode-list-inactiveSelectionBackground);
|
||||
}
|
||||
|
||||
#body:focus-within .row.selected,
|
||||
#body:focus .row.selected {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
}
|
||||
|
||||
.row .td {
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.td.code {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
font-size: var(--vscode-editor-font-size);
|
||||
color: var(--cr-fg-default);
|
||||
}
|
||||
|
||||
.td.line {
|
||||
text-align: right;
|
||||
color: var(--vscode-editorLineNumber-foreground);
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
|
||||
.td.file,
|
||||
.td.project,
|
||||
.td.member {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.td.file .name {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* --- group headers ------------------------------------------------------ */
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: var(--row-height);
|
||||
padding: 0 8px;
|
||||
font-weight: 600;
|
||||
background: var(--vscode-sideBarSectionHeader-background);
|
||||
border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group .twisty {
|
||||
flex: 0 0 auto;
|
||||
width: 10px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.group .count,
|
||||
.group .project {
|
||||
font-weight: normal;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
|
||||
/* --- code tokens -------------------------------------------------------- */
|
||||
|
||||
.tok-comment { color: var(--cr-comment); font-style: italic; }
|
||||
.tok-string { color: var(--cr-string); }
|
||||
.tok-keyword { color: var(--cr-keyword); }
|
||||
.tok-control { color: var(--cr-control); }
|
||||
.tok-number { color: var(--cr-number); }
|
||||
.tok-type { color: var(--cr-type); }
|
||||
.tok-call { color: var(--cr-call); }
|
||||
.tok-punct { color: var(--cr-fg-default); }
|
||||
|
||||
.hit {
|
||||
background: var(--vscode-editor-findMatchHighlightBackground);
|
||||
border: 1px solid var(--vscode-editor-findMatchHighlightBorder, transparent);
|
||||
border-radius: 2px;
|
||||
margin: 0 -1px;
|
||||
}
|
||||
|
||||
|
||||
/* --- empty state -------------------------------------------------------- */
|
||||
|
||||
#empty {
|
||||
padding: 16px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
#empty[hidden] {
|
||||
display: none;
|
||||
}
|
||||
+633
@@ -0,0 +1,633 @@
|
||||
// @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' });
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><g fill="#C5C5C5"><rect x="2" y="2.5" width="5" height="1.6" rx="0.6"/><rect x="8.5" y="2.5" width="5.5" height="1.6" rx="0.6" opacity=".55"/><rect x="2" y="7.2" width="7" height="1.6" rx="0.6"/><rect x="10.5" y="7.2" width="3.5" height="1.6" rx="0.6" opacity=".55"/><rect x="2" y="11.9" width="4" height="1.6" rx="0.6"/><rect x="7.5" y="11.9" width="6.5" height="1.6" rx="0.6" opacity=".55"/></g></svg>
|
||||
|
After Width: | Height: | Size: 486 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><g fill="#424242"><rect x="2" y="2.5" width="5" height="1.6" rx="0.6"/><rect x="8.5" y="2.5" width="5.5" height="1.6" rx="0.6" opacity=".55"/><rect x="2" y="7.2" width="7" height="1.6" rx="0.6"/><rect x="10.5" y="7.2" width="3.5" height="1.6" rx="0.6" opacity=".55"/><rect x="2" y="11.9" width="4" height="1.6" rx="0.6"/><rect x="7.5" y="11.9" width="6.5" height="1.6" rx="0.6" opacity=".55"/></g></svg>
|
||||
|
After Width: | Height: | Size: 486 B |
Generated
+1380
File diff suppressed because it is too large
Load Diff
+165
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"name": "colored-references",
|
||||
"displayName": "Colored References",
|
||||
"description": "Find All References results in a syntax-highlighted editor pane, Visual Studio style. Works with any language server (DotRush, C# Dev Kit, OmniSharp, ...).",
|
||||
"version": "0.1.0",
|
||||
"publisher": "local",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"references",
|
||||
"find all references",
|
||||
"csharp",
|
||||
"dotnet",
|
||||
"visual studio"
|
||||
],
|
||||
"activationEvents": [],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "coloredReferences.find",
|
||||
"title": "Find All References (Colored)",
|
||||
"category": "References"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.findInDocument",
|
||||
"title": "Find All References in Colored Editor",
|
||||
"category": "References"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.findInPanel",
|
||||
"title": "Find All References in Results Panel",
|
||||
"category": "References"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.toggleView",
|
||||
"title": "Switch Between Editor and Panel View",
|
||||
"category": "References"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.goTo",
|
||||
"title": "Go to Reference",
|
||||
"category": "References"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.refresh",
|
||||
"title": "Refresh References",
|
||||
"category": "References",
|
||||
"icon": "$(refresh)"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "coloredReferences.find",
|
||||
"key": "ctrl+alt+f12",
|
||||
"mac": "cmd+alt+f12",
|
||||
"when": "editorHasReferenceProvider && editorTextFocus"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.goTo",
|
||||
"key": "enter",
|
||||
"when": "editorTextFocus && resourceScheme == colored-refs && !suggestWidgetVisible"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.refresh",
|
||||
"key": "f5",
|
||||
"when": "editorTextFocus && resourceScheme == colored-refs"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.toggleView",
|
||||
"key": "ctrl+alt+shift+f12",
|
||||
"mac": "cmd+alt+shift+f12",
|
||||
"when": "resourceScheme == colored-refs || activeWebviewPanelId == coloredReferences.results"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "coloredReferences.find",
|
||||
"when": "editorHasReferenceProvider",
|
||||
"group": "navigation@1.5"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "coloredReferences.refresh",
|
||||
"when": "resourceScheme == colored-refs",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.toggleView",
|
||||
"when": "resourceScheme == colored-refs",
|
||||
"group": "navigation"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "coloredReferences.goTo",
|
||||
"when": "resourceScheme == colored-refs"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.refresh",
|
||||
"when": "resourceScheme == colored-refs"
|
||||
},
|
||||
{
|
||||
"command": "coloredReferences.toggleView",
|
||||
"when": "resourceScheme == colored-refs || activeWebviewPanelId == coloredReferences.results"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Colored References",
|
||||
"properties": {
|
||||
"coloredReferences.openBeside": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Open the results pane beside the current editor instead of in the same group."
|
||||
},
|
||||
"coloredReferences.showProject": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show the containing project (nearest .csproj / .fsproj / .vbproj) in file headers."
|
||||
},
|
||||
"coloredReferences.reuseTab": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Reuse a single results tab instead of opening a new one per search."
|
||||
},
|
||||
"coloredReferences.view": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"document",
|
||||
"panel"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"A read-only editor pane in the same language as the source, coloured by your theme.",
|
||||
"A webview panel with resizable, sortable columns and a containing-member column."
|
||||
],
|
||||
"default": "document",
|
||||
"description": "Which view Find All References (Colored) opens."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run compile",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile",
|
||||
"test": "node ./out/test/runTest.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"mocha": "^10.8.2",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { PanelManager, ReferencePanel } from './panel';
|
||||
import {
|
||||
CODE_INDENT, Origin, ReferenceResults, RenderedDocument, ResultLine, SCHEME,
|
||||
findReferences, gather, invalidateSymbols, relativePath, renderDocument,
|
||||
} from './references';
|
||||
|
||||
type View = 'document' | 'panel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StoredDocument extends RenderedDocument {
|
||||
results: ReferenceResults;
|
||||
}
|
||||
|
||||
class ResultsStore {
|
||||
private readonly docs = new Map<string, StoredDocument>();
|
||||
private counter = 0;
|
||||
/** The results document opened most recently, so `reuseTab` can replace it. */
|
||||
current: vscode.Uri | undefined;
|
||||
readonly onDidChange = new vscode.EventEmitter<vscode.Uri>();
|
||||
|
||||
/**
|
||||
* Builds a URI whose path doubles as the tab title. The source file's extension is
|
||||
* kept so VS Code picks the language (and therefore the grammar) from the URI itself —
|
||||
* calling `setTextDocumentLanguage` instead would close and re-open the document.
|
||||
*/
|
||||
createUri(symbol: string, source: vscode.Uri, reuse: boolean): vscode.Uri {
|
||||
const safeSymbol = symbol.replace(/[\\/?#]/g, '_');
|
||||
const title = `References to ${safeSymbol}${path.extname(source.path)}`;
|
||||
// A query keeps the URI unique when not reusing; the tab shows only the path.
|
||||
const query = reuse ? 'r' : String(++this.counter);
|
||||
return vscode.Uri.from({ scheme: SCHEME, path: `/${title}`, query });
|
||||
}
|
||||
|
||||
set(uri: vscode.Uri, doc: StoredDocument): void {
|
||||
this.docs.set(uri.toString(), doc);
|
||||
this.current = uri;
|
||||
this.onDidChange.fire(uri);
|
||||
}
|
||||
|
||||
get(uri: vscode.Uri): StoredDocument | undefined {
|
||||
return this.docs.get(uri.toString());
|
||||
}
|
||||
|
||||
has(uri: vscode.Uri): boolean {
|
||||
return this.docs.has(uri.toString());
|
||||
}
|
||||
|
||||
delete(uri: vscode.Uri): void {
|
||||
this.docs.delete(uri.toString());
|
||||
if (this.current?.toString() === uri.toString()) {
|
||||
this.current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = new ResultsStore();
|
||||
let panels: PanelManager;
|
||||
|
||||
/** Open tabs showing a given results URI. */
|
||||
function tabsFor(uri: vscode.Uri): vscode.Tab[] {
|
||||
return vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => {
|
||||
const input = tab.input as { uri?: vscode.Uri } | undefined;
|
||||
return input?.uri?.scheme === SCHEME && input.uri.toString() === uri.toString();
|
||||
});
|
||||
}
|
||||
|
||||
function config() {
|
||||
return vscode.workspace.getConfiguration('coloredReferences');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decorations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lineNumberDecoration = vscode.window.createTextEditorDecorationType({
|
||||
before: {
|
||||
color: new vscode.ThemeColor('editorLineNumber.foreground'),
|
||||
margin: '0 1ch 0 0',
|
||||
width: '5ch',
|
||||
textDecoration: 'none; text-align: right; display: inline-block',
|
||||
},
|
||||
});
|
||||
|
||||
const symbolDecoration = vscode.window.createTextEditorDecorationType({
|
||||
backgroundColor: new vscode.ThemeColor('editor.findMatchHighlightBackground'),
|
||||
border: '1px solid',
|
||||
borderColor: new vscode.ThemeColor('editor.findMatchHighlightBorder'),
|
||||
borderRadius: '2px',
|
||||
});
|
||||
|
||||
const headerDecoration = vscode.window.createTextEditorDecorationType({
|
||||
isWholeLine: true,
|
||||
fontWeight: 'bold',
|
||||
color: new vscode.ThemeColor('foreground'),
|
||||
backgroundColor: new vscode.ThemeColor('sideBarSectionHeader.background'),
|
||||
});
|
||||
|
||||
const titleDecoration = vscode.window.createTextEditorDecorationType({
|
||||
isWholeLine: true,
|
||||
color: new vscode.ThemeColor('descriptionForeground'),
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
|
||||
function applyDecorations(editor: vscode.TextEditor): void {
|
||||
const doc = store.get(editor.document.uri);
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numbers: vscode.DecorationOptions[] = [];
|
||||
const symbols: vscode.Range[] = [];
|
||||
const headers: vscode.Range[] = [];
|
||||
const titles: vscode.Range[] = [];
|
||||
|
||||
doc.lines.forEach((line, i) => {
|
||||
switch (line.kind) {
|
||||
case 'code': {
|
||||
numbers.push({
|
||||
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));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'header':
|
||||
headers.push(new vscode.Range(i, 0, i, 0));
|
||||
break;
|
||||
case 'title':
|
||||
titles.push(new vscode.Range(i, 0, i, 0));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
editor.setDecorations(lineNumberDecoration, numbers);
|
||||
editor.setDecorations(symbolDecoration, symbols);
|
||||
editor.setDecorations(headerDecoration, headers);
|
||||
editor.setDecorations(titleDecoration, titles);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Running a search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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);
|
||||
|
||||
const reuse = cfg.get<boolean>('reuseTab', true);
|
||||
const uri = targetUri ?? store.createUri(results.symbol, results.origin.uri, reuse);
|
||||
|
||||
// The symbol is part of the URI (it is the tab title), so searching for a different
|
||||
// symbol produces a different URI. To keep a single tab, close the previous results
|
||||
// and take over its group.
|
||||
let column: vscode.ViewColumn | undefined;
|
||||
const previous = store.current;
|
||||
if (!targetUri && reuse && previous && previous.toString() !== uri.toString()) {
|
||||
const stale = tabsFor(previous);
|
||||
column = stale[0]?.group.viewColumn;
|
||||
if (stale.length > 0) {
|
||||
await vscode.window.tabGroups.close(stale, true);
|
||||
}
|
||||
}
|
||||
|
||||
store.set(uri, { ...rendered, results });
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
if (doc.languageId !== results.languageId) {
|
||||
// Only needed when the source extension is not mapped to a language (rare).
|
||||
// This closes and re-opens the document, which is why we avoid it by default.
|
||||
await vscode.languages.setTextDocumentLanguage(doc, results.languageId);
|
||||
}
|
||||
|
||||
// Reuse the group that already holds this results document. Checking the tabs rather
|
||||
// than the visible editors matters when the tab is hidden behind a results panel:
|
||||
// opening "beside" would then duplicate it into a new group.
|
||||
const existing = tabsFor(uri)[0]?.group.viewColumn;
|
||||
const editor = await vscode.window.showTextDocument(doc, {
|
||||
viewColumn: existing ?? column ??
|
||||
(cfg.get<boolean>('openBeside', true) ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active),
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
// Put the cursor on the first result.
|
||||
const firstCode = rendered.lines.findIndex(l => l.kind === 'code');
|
||||
if (firstCode >= 0) {
|
||||
const pos = new vscode.Position(firstCode, CODE_INDENT.length);
|
||||
editor.selection = new vscode.Selection(pos, pos);
|
||||
editor.revealRange(new vscode.Range(0, 0, firstCode, 0), vscode.TextEditorRevealType.AtTop);
|
||||
}
|
||||
applyDecorations(editor);
|
||||
}
|
||||
|
||||
async function showPanel(results: ReferenceResults, target?: ReferencePanel): Promise<void> {
|
||||
if (target) {
|
||||
await target.update(results);
|
||||
return;
|
||||
}
|
||||
const cfg = config();
|
||||
const column = cfg.get<boolean>('openBeside', true) ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active;
|
||||
await panels.show(results, cfg.get<boolean>('reuseTab', true), column);
|
||||
}
|
||||
|
||||
interface SearchTarget {
|
||||
/** Refresh an existing results document in place. */
|
||||
uri?: vscode.Uri;
|
||||
/** Refresh an existing panel in place. */
|
||||
panel?: ReferencePanel;
|
||||
/** Which view to open for a new search. */
|
||||
view?: View;
|
||||
}
|
||||
|
||||
async function runSearch(
|
||||
origin: Origin, languageId: string, symbol: string, target: SearchTarget = {},
|
||||
): Promise<void> {
|
||||
const locations = await findReferences(origin, symbol);
|
||||
if (locations.length === 0) {
|
||||
vscode.window.setStatusBarMessage(`No references found for '${symbol}'`, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await gather(
|
||||
symbol, languageId, origin, locations, config().get<boolean>('showProject', true));
|
||||
|
||||
if (target.panel) {
|
||||
await showPanel(results, target.panel);
|
||||
} else if (target.uri) {
|
||||
await showDocument(results, target.uri);
|
||||
} else if ((target.view ?? config().get<View>('view', 'document')) === 'panel') {
|
||||
await showPanel(results);
|
||||
} else {
|
||||
await showDocument(results);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function findCommand(view?: View): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document.uri.scheme === SCHEME) {
|
||||
return;
|
||||
}
|
||||
const position = editor.selection.active;
|
||||
const wordRange = editor.document.getWordRangeAtPosition(position);
|
||||
const selected = editor.document.getText(editor.selection).trim();
|
||||
const symbol = wordRange ? editor.document.getText(wordRange) : selected;
|
||||
if (!symbol) {
|
||||
vscode.window.setStatusBarMessage('No symbol under the cursor', 4000);
|
||||
return;
|
||||
}
|
||||
await runSearch({ uri: editor.document.uri, position }, editor.document.languageId, symbol, { view });
|
||||
}
|
||||
|
||||
function targetLocation(document: vscode.TextDocument, position: vscode.Position): vscode.Location | undefined {
|
||||
const results = store.get(document.uri);
|
||||
const line: ResultLine | undefined = results?.lines[position.line];
|
||||
if (!line?.file) {
|
||||
return undefined;
|
||||
}
|
||||
if (line.kind === 'header') {
|
||||
return new vscode.Location(line.file, new vscode.Position(0, 0));
|
||||
}
|
||||
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];
|
||||
}
|
||||
return new vscode.Location(line.file, new vscode.Position(line.sourceLine ?? 0, col));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function goToCommand(): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document.uri.scheme !== SCHEME) {
|
||||
return;
|
||||
}
|
||||
const loc = targetLocation(editor.document, editor.selection.active);
|
||||
if (!loc) {
|
||||
return;
|
||||
}
|
||||
// Open in the group the search was started from, if it's still around; otherwise beside.
|
||||
const results = store.get(editor.document.uri);
|
||||
const originEditor = vscode.window.visibleTextEditors.find(
|
||||
e => e.document.uri.toString() === results?.results.origin.uri.toString());
|
||||
const target = await vscode.window.showTextDocument(loc.uri, {
|
||||
viewColumn: originEditor?.viewColumn ?? vscode.ViewColumn.Beside,
|
||||
preview: true,
|
||||
selection: loc.range,
|
||||
});
|
||||
target.revealRange(loc.range, vscode.TextEditorRevealType.InCenterIfOutsideViewport);
|
||||
}
|
||||
|
||||
async function refreshCommand(): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document.uri.scheme !== SCHEME) {
|
||||
return;
|
||||
}
|
||||
const stored = store.get(editor.document.uri);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
const { origin, languageId, symbol } = stored.results;
|
||||
await runSearch(origin, languageId, symbol, { uri: editor.document.uri });
|
||||
}
|
||||
|
||||
function refreshPanel(panel: ReferencePanel): void {
|
||||
const origin = panel.origin;
|
||||
if (!origin) {
|
||||
return;
|
||||
}
|
||||
void runSearch(origin, panel.languageId, panel.symbol, { panel });
|
||||
}
|
||||
|
||||
/** Re-opens the results under the cursor in the other view. */
|
||||
async function toggleViewCommand(): Promise<void> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const stored = editor && editor.document.uri.scheme === SCHEME
|
||||
? store.get(editor.document.uri)
|
||||
: undefined;
|
||||
if (stored) {
|
||||
await showPanel(stored.results);
|
||||
return;
|
||||
}
|
||||
// Coming from a panel: the panel is the active tab, so there is no active editor.
|
||||
const active = panels.active;
|
||||
if (active?.shown) {
|
||||
await showDocument(active.shown);
|
||||
return;
|
||||
}
|
||||
vscode.window.setStatusBarMessage('No reference results to switch', 4000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
panels = new PanelManager(context.extensionUri, refreshPanel);
|
||||
|
||||
const contentProvider: vscode.TextDocumentContentProvider = {
|
||||
onDidChange: store.onDidChange.event,
|
||||
provideTextDocumentContent: uri => store.get(uri)?.content ?? '',
|
||||
};
|
||||
|
||||
const definitionProvider: vscode.DefinitionProvider = {
|
||||
provideDefinition: (document, position) => targetLocation(document, position),
|
||||
};
|
||||
|
||||
const hoverProvider: vscode.HoverProvider = {
|
||||
provideHover: (document, position) => {
|
||||
const loc = targetLocation(document, position);
|
||||
if (!loc) {
|
||||
return undefined;
|
||||
}
|
||||
const md = new vscode.MarkdownString(
|
||||
`${relativePath(loc.uri)}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`);
|
||||
return new vscode.Hover(md);
|
||||
},
|
||||
};
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider),
|
||||
vscode.languages.registerDefinitionProvider({ scheme: SCHEME }, definitionProvider),
|
||||
vscode.languages.registerHoverProvider({ scheme: SCHEME }, hoverProvider),
|
||||
|
||||
vscode.commands.registerCommand('coloredReferences.find', () => findCommand()),
|
||||
vscode.commands.registerCommand('coloredReferences.findInDocument', () => findCommand('document')),
|
||||
vscode.commands.registerCommand('coloredReferences.findInPanel', () => findCommand('panel')),
|
||||
vscode.commands.registerCommand('coloredReferences.goTo', goToCommand),
|
||||
vscode.commands.registerCommand('coloredReferences.refresh', refreshCommand),
|
||||
vscode.commands.registerCommand('coloredReferences.toggleView', toggleViewCommand),
|
||||
|
||||
// Decorations are per-editor and vanish when the editor is recreated.
|
||||
vscode.window.onDidChangeVisibleTextEditors(editors => {
|
||||
for (const e of editors) {
|
||||
if (e.document.uri.scheme === SCHEME) {
|
||||
applyDecorations(e);
|
||||
}
|
||||
}
|
||||
}),
|
||||
vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
if (editor?.document.uri.scheme === SCHEME) {
|
||||
applyDecorations(editor);
|
||||
}
|
||||
}),
|
||||
vscode.workspace.onDidChangeTextDocument(e => {
|
||||
if (e.document.uri.scheme === SCHEME) {
|
||||
for (const ed of vscode.window.visibleTextEditors) {
|
||||
if (ed.document === e.document) {
|
||||
applyDecorations(ed);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The containing-member column is derived from document symbols.
|
||||
invalidateSymbols(e.document.uri);
|
||||
}
|
||||
}),
|
||||
vscode.workspace.onDidCloseTextDocument(doc => {
|
||||
if (doc.uri.scheme !== SCHEME) {
|
||||
return;
|
||||
}
|
||||
// A close is not proof the tab is gone: changing a document's language, or
|
||||
// VS Code recreating the model, closes and re-opens the same URI. Dropping the
|
||||
// results here would leave a tab that still shows text but cannot navigate.
|
||||
const uri = doc.uri;
|
||||
setTimeout(() => {
|
||||
const stillOpen = tabsFor(uri).length > 0 ||
|
||||
vscode.workspace.textDocuments.some(d => d.uri.toString() === uri.toString());
|
||||
if (!stillOpen) {
|
||||
store.delete(uri);
|
||||
}
|
||||
}, 0);
|
||||
}),
|
||||
|
||||
lineNumberDecoration,
|
||||
symbolDecoration,
|
||||
headerDecoration,
|
||||
titleDecoration,
|
||||
store.onDidChange,
|
||||
{ dispose: () => panels.dispose() },
|
||||
);
|
||||
|
||||
// Results live in memory only, so any results tab restored from a previous window
|
||||
// is dead: it would render as an empty document that cannot navigate anywhere.
|
||||
const orphaned = vscode.window.tabGroups.all.flatMap(group => group.tabs).filter(tab => {
|
||||
const input = tab.input as { uri?: vscode.Uri } | undefined;
|
||||
return input?.uri?.scheme === SCHEME && !store.has(input.uri);
|
||||
});
|
||||
if (orphaned.length > 0) {
|
||||
void vscode.window.tabGroups.close(orphaned, true);
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// nothing to clean up beyond subscriptions
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ReferenceResults, containingMembers, displayLine, relativePath,
|
||||
} from './references';
|
||||
|
||||
/** One reference, as shown in a table row. */
|
||||
export interface PanelRow {
|
||||
id: number;
|
||||
/** Trimmed source line. */
|
||||
code: string;
|
||||
/** Column ranges of this reference within `code`. */
|
||||
hits: [number, number][];
|
||||
/** File name, shown first in the File column. */
|
||||
file: string;
|
||||
/** Directory, shown dimmed after the file name. */
|
||||
dir: string;
|
||||
relPath: string;
|
||||
/** 1-based, for display. */
|
||||
line: number;
|
||||
col: number;
|
||||
project: string;
|
||||
member: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens results into one row per reference — a line holding two references becomes
|
||||
* two rows, so sorting by line and column stays meaningful.
|
||||
*
|
||||
* Returns the rows plus the location each row navigates to, keyed by row id.
|
||||
*/
|
||||
export function buildRows(
|
||||
results: ReferenceResults, members: Map<string, string>,
|
||||
): { rows: PanelRow[]; locations: Map<number, vscode.Location> } {
|
||||
const rows: PanelRow[] = [];
|
||||
const locations = new Map<number, vscode.Location>();
|
||||
|
||||
let id = 0;
|
||||
for (const file of results.files) {
|
||||
const relative = relativePath(file.uri);
|
||||
const dir = path.dirname(relative);
|
||||
for (const source of file.lines) {
|
||||
const { text, symbolRanges } = displayLine(source);
|
||||
const member = members.get(`${file.uri.toString()}|${source.line}`) ?? '';
|
||||
source.ranges.forEach((range, index) => {
|
||||
rows.push({
|
||||
id,
|
||||
code: text,
|
||||
hits: [symbolRanges[index]],
|
||||
file: path.basename(relative),
|
||||
dir: dir === '.' ? '' : dir,
|
||||
relPath: relative,
|
||||
line: source.line + 1,
|
||||
col: range.start.character + 1,
|
||||
project: file.project ?? '',
|
||||
member,
|
||||
});
|
||||
locations.set(id, new vscode.Location(file.uri, range.start));
|
||||
id++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, locations };
|
||||
}
|
||||
|
||||
function nonce(): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let out = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A results panel: the same references as the virtual document, as a table with
|
||||
* resizable, sortable columns plus a containing-member column.
|
||||
*/
|
||||
export class ReferencePanel {
|
||||
static readonly viewType = 'coloredReferences.results';
|
||||
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
private locations = new Map<number, vscode.Location>();
|
||||
private results: ReferenceResults | undefined;
|
||||
/** Set once the webview has loaded and is listening for results. */
|
||||
private ready = false;
|
||||
private pending: unknown;
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
column: vscode.ViewColumn,
|
||||
private readonly onRefresh: (panel: ReferencePanel) => void,
|
||||
private readonly onDispose: (panel: ReferencePanel) => void,
|
||||
) {
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
ReferencePanel.viewType,
|
||||
'References',
|
||||
{ viewColumn: column, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
// Ctrl+F focuses the panel's own filter box instead.
|
||||
enableFindWidget: false,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'media')],
|
||||
},
|
||||
);
|
||||
this.panel.iconPath = {
|
||||
light: vscode.Uri.joinPath(extensionUri, 'media', 'references-light.svg'),
|
||||
dark: vscode.Uri.joinPath(extensionUri, 'media', 'references-dark.svg'),
|
||||
};
|
||||
this.panel.webview.html = this.html();
|
||||
|
||||
this.disposables.push(
|
||||
this.panel.webview.onDidReceiveMessage(message => void this.receive(message)),
|
||||
this.panel.onDidDispose(() => this.dispose()),
|
||||
);
|
||||
}
|
||||
|
||||
get viewColumn(): vscode.ViewColumn | undefined {
|
||||
return this.panel.viewColumn;
|
||||
}
|
||||
|
||||
get origin(): ReferenceResults['origin'] | undefined {
|
||||
return this.results?.origin;
|
||||
}
|
||||
|
||||
get symbol(): string {
|
||||
return this.results?.symbol ?? '';
|
||||
}
|
||||
|
||||
get languageId(): string {
|
||||
return this.results?.languageId ?? '';
|
||||
}
|
||||
|
||||
get shown(): ReferenceResults | undefined {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.panel.active;
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
this.panel.reveal(this.panel.viewColumn, false);
|
||||
}
|
||||
|
||||
async update(results: ReferenceResults): Promise<void> {
|
||||
this.results = results;
|
||||
this.panel.title = `References to ${results.symbol}`;
|
||||
|
||||
const members = await containingMembers(results);
|
||||
const { rows, locations } = buildRows(results, members);
|
||||
this.locations = locations;
|
||||
|
||||
this.post({
|
||||
type: 'results',
|
||||
symbol: results.symbol,
|
||||
languageId: results.languageId,
|
||||
fileCount: results.files.length,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
|
||||
private post(message: unknown): void {
|
||||
if (this.ready) {
|
||||
void this.panel.webview.postMessage(message);
|
||||
} else {
|
||||
// The webview asks for its results as soon as its script runs.
|
||||
this.pending = message;
|
||||
}
|
||||
}
|
||||
|
||||
private async receive(message: { type: string; id?: number; preview?: boolean; relPath?: string }):
|
||||
Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'ready':
|
||||
this.ready = true;
|
||||
if (this.pending) {
|
||||
void this.panel.webview.postMessage(this.pending);
|
||||
this.pending = undefined;
|
||||
}
|
||||
return;
|
||||
case 'open':
|
||||
return this.open(message.id ?? -1, message.preview ?? true);
|
||||
case 'openFile':
|
||||
return this.openFile(message.relPath ?? '');
|
||||
case 'refresh':
|
||||
this.onRefresh(this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** The group a source file should open in: never the one holding this panel. */
|
||||
private sourceColumn(): vscode.ViewColumn {
|
||||
const editors = vscode.window.visibleTextEditors.filter(e => e.viewColumn !== this.panel.viewColumn);
|
||||
const origin = editors.find(e => e.document.uri.toString() === this.results?.origin.uri.toString());
|
||||
return origin?.viewColumn ?? editors[0]?.viewColumn ?? vscode.ViewColumn.Beside;
|
||||
}
|
||||
|
||||
private async open(id: number, preview: boolean): Promise<void> {
|
||||
const location = this.locations.get(id);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const editor = await vscode.window.showTextDocument(location.uri, {
|
||||
viewColumn: this.sourceColumn(),
|
||||
preview,
|
||||
selection: location.range,
|
||||
// Single click browses without stealing focus; Enter / double-click jumps.
|
||||
preserveFocus: preview,
|
||||
});
|
||||
editor.revealRange(location.range, vscode.TextEditorRevealType.InCenterIfOutsideViewport);
|
||||
}
|
||||
|
||||
private async openFile(relPath: string): Promise<void> {
|
||||
const file = this.results?.files.find(f => relativePath(f.uri) === relPath);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
await vscode.window.showTextDocument(file.uri, {
|
||||
viewColumn: this.sourceColumn(),
|
||||
preview: false,
|
||||
});
|
||||
}
|
||||
|
||||
private html(): string {
|
||||
const webview = this.panel.webview;
|
||||
const asset = (name: string) =>
|
||||
webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'media', name));
|
||||
const token = nonce();
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; ` +
|
||||
`style-src ${webview.cspSource}; script-src 'nonce-${token}'; font-src ${webview.cspSource};">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="${asset('panel.css')}" rel="stylesheet">
|
||||
<title>References</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="toolbar">
|
||||
<span id="summary"></span>
|
||||
<input id="filter" type="text" placeholder="Filter results (Ctrl+F)" spellcheck="false">
|
||||
<button id="toggle-group" class="toolbar-button" aria-pressed="true"
|
||||
title="Group results by file">Group by file</button>
|
||||
<button id="refresh" class="toolbar-button" title="Re-run the search (F5)">Refresh</button>
|
||||
</div>
|
||||
<div id="table">
|
||||
<div id="head" role="row"></div>
|
||||
<div id="body" role="rowgroup" tabindex="0"></div>
|
||||
</div>
|
||||
<div id="empty" hidden></div>
|
||||
</div>
|
||||
<script nonce="${token}" src="${asset('panel.js')}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.onDispose(this);
|
||||
for (const disposable of this.disposables.splice(0)) {
|
||||
disposable.dispose();
|
||||
}
|
||||
this.panel.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Owns the open panels and decides whether a search reuses one. */
|
||||
export class PanelManager {
|
||||
private readonly panels: ReferencePanel[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly refresh: (panel: ReferencePanel) => void,
|
||||
) { }
|
||||
|
||||
/** The focused panel, falling back to the most recent one. */
|
||||
get active(): ReferencePanel | undefined {
|
||||
return this.panels.find(p => p.isActive) ?? this.panels[this.panels.length - 1];
|
||||
}
|
||||
|
||||
/** Shows `results`, reusing the most recent panel when `reuse` is set. */
|
||||
async show(results: ReferenceResults, reuse: boolean, column: vscode.ViewColumn): Promise<ReferencePanel> {
|
||||
const panel = (reuse ? this.panels[this.panels.length - 1] : undefined) ?? this.create(column);
|
||||
await panel.update(results);
|
||||
panel.reveal();
|
||||
return panel;
|
||||
}
|
||||
|
||||
private create(column: vscode.ViewColumn): ReferencePanel {
|
||||
const panel = new ReferencePanel(
|
||||
this.extensionUri, column, this.refresh,
|
||||
closed => {
|
||||
const index = this.panels.indexOf(closed);
|
||||
if (index >= 0) {
|
||||
this.panels.splice(index, 1);
|
||||
}
|
||||
});
|
||||
this.panels.push(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const panel of this.panels.splice(0)) {
|
||||
panel.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
|
||||
export const SCHEME = 'colored-refs';
|
||||
export const CODE_INDENT = ' '; // room for the line-number decoration
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** All references that share one source line. */
|
||||
export interface ReferenceLine {
|
||||
/** 0-based source line. */
|
||||
line: number;
|
||||
/** Reference ranges on this line, sorted by column. */
|
||||
ranges: vscode.Range[];
|
||||
/** The raw source line. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ReferenceFile {
|
||||
uri: vscode.Uri;
|
||||
/** Nearest .csproj / .fsproj / .vbproj, without extension. */
|
||||
project: string | undefined;
|
||||
lines: ReferenceLine[];
|
||||
/** Reference count in this file (a line can hold several). */
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Origin {
|
||||
uri: vscode.Uri;
|
||||
position: vscode.Position;
|
||||
}
|
||||
|
||||
/** The result of one Find All References, independent of how it is displayed. */
|
||||
export interface ReferenceResults {
|
||||
symbol: string;
|
||||
languageId: string;
|
||||
/** Where the search was started, so it can be re-run. */
|
||||
origin: Origin;
|
||||
files: ReferenceFile[];
|
||||
/** Total references, after de-duplication. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** One displayed line in the virtual results document. */
|
||||
export interface ResultLine {
|
||||
kind: 'code' | 'header' | 'title' | 'blank';
|
||||
/** Source file this line belongs to (code + header). */
|
||||
file?: vscode.Uri;
|
||||
/** 0-based source line (code) */
|
||||
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][];
|
||||
}
|
||||
|
||||
export interface RenderedDocument {
|
||||
content: string;
|
||||
lines: ResultLine[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading source files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COMMENT_PREFIX: Record<string, string> = {
|
||||
python: '#',
|
||||
ruby: '#',
|
||||
shellscript: '#',
|
||||
perl: '#',
|
||||
r: '#',
|
||||
yaml: '#',
|
||||
powershell: '#',
|
||||
lua: '--',
|
||||
sql: '--',
|
||||
haskell: '--',
|
||||
vb: "'",
|
||||
fsharp: '//',
|
||||
csharp: '//',
|
||||
};
|
||||
|
||||
export function commentPrefix(languageId: string): string {
|
||||
return COMMENT_PREFIX[languageId] ?? '//';
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
|
||||
/** Reads a file's lines, preferring the in-memory (possibly unsaved) version. */
|
||||
async function readLines(uri: vscode.Uri): Promise<string[]> {
|
||||
const open = vscode.workspace.textDocuments.find(d => d.uri.toString() === uri.toString());
|
||||
if (open) {
|
||||
return open.getText().split(/\r?\n/);
|
||||
}
|
||||
try {
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
return decoder.decode(bytes).split(/\r?\n/);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const projectCache = new Map<string, Promise<string | undefined>>();
|
||||
const PROJECT_EXTENSIONS = new Set(['.csproj', '.fsproj', '.vbproj']);
|
||||
|
||||
/** Walks up from the file to the workspace root looking for a project file. */
|
||||
function findProject(fileUri: vscode.Uri): Promise<string | undefined> {
|
||||
const dir = path.dirname(fileUri.fsPath);
|
||||
const cached = projectCache.get(dir);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const root = vscode.workspace.getWorkspaceFolder(fileUri)?.uri.fsPath;
|
||||
let current = dir;
|
||||
for (let depth = 0; depth < 30; depth++) {
|
||||
try {
|
||||
const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(current));
|
||||
const proj = entries.find(([name, type]) =>
|
||||
type === vscode.FileType.File && PROJECT_EXTENSIONS.has(path.extname(name).toLowerCase()));
|
||||
if (proj) {
|
||||
return path.basename(proj[0], path.extname(proj[0]));
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!root || current === root || path.dirname(current) === current) {
|
||||
return undefined;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
projectCache.set(dir, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function relativePath(uri: vscode.Uri): string {
|
||||
return vscode.workspace.asRelativePath(uri, vscode.workspace.workspaceFolders?.length !== 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gathering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Runs the reference request through every registered provider. */
|
||||
export async function findReferences(origin: Origin, symbol: string): Promise<vscode.Location[]> {
|
||||
const locations = await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Window, title: `Finding references to '${symbol}'…` },
|
||||
() => vscode.commands.executeCommand<vscode.Location[]>(
|
||||
'vscode.executeReferenceProvider', origin.uri, origin.position),
|
||||
);
|
||||
return locations ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups raw locations by file and source line, reads the source text, and resolves
|
||||
* the containing project. Duplicate ranges (two language servers answering the same
|
||||
* request) collapse into one.
|
||||
*/
|
||||
export async function gather(
|
||||
symbol: string,
|
||||
languageId: string,
|
||||
origin: Origin,
|
||||
locations: vscode.Location[],
|
||||
showProject: boolean,
|
||||
): Promise<ReferenceResults> {
|
||||
const byFile = new Map<string, { uri: vscode.Uri; byLine: Map<number, vscode.Range[]> }>();
|
||||
for (const loc of locations) {
|
||||
const key = loc.uri.toString();
|
||||
let entry = byFile.get(key);
|
||||
if (!entry) {
|
||||
entry = { uri: loc.uri, byLine: new Map() };
|
||||
byFile.set(key, entry);
|
||||
}
|
||||
const line = loc.range.start.line;
|
||||
const ranges = entry.byLine.get(line) ?? [];
|
||||
// De-duplicate identical ranges.
|
||||
if (!ranges.some(r => r.isEqual(loc.range))) {
|
||||
ranges.push(loc.range);
|
||||
}
|
||||
entry.byLine.set(line, ranges);
|
||||
}
|
||||
|
||||
const sorted = [...byFile.values()].sort((a, b) => a.uri.fsPath.localeCompare(b.uri.fsPath));
|
||||
|
||||
const files = await Promise.all(sorted.map(async (entry): Promise<ReferenceFile> => {
|
||||
const [sourceLines, project] = await Promise.all([
|
||||
readLines(entry.uri),
|
||||
showProject ? findProject(entry.uri) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const lines = [...entry.byLine.keys()].sort((a, b) => a - b).map((line): ReferenceLine => ({
|
||||
line,
|
||||
ranges: entry.byLine.get(line)!.sort((a, b) => a.start.character - b.start.character),
|
||||
text: sourceLines[line] ?? '',
|
||||
}));
|
||||
|
||||
return {
|
||||
uri: entry.uri,
|
||||
project,
|
||||
lines,
|
||||
count: lines.reduce((n, l) => n + l.ranges.length, 0),
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
symbol,
|
||||
languageId,
|
||||
origin,
|
||||
files,
|
||||
total: files.reduce((n, f) => n + f.count, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering the virtual document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trims a source line for display and maps the reference ranges onto the trimmed text.
|
||||
* Displayed offsets differ from source offsets by a constant per line.
|
||||
*/
|
||||
export function displayLine(
|
||||
source: ReferenceLine, indent = '',
|
||||
): { text: string; symbolRanges: [number, number][] } {
|
||||
const trimmed = source.text.trimStart();
|
||||
const removed = source.text.length - trimmed.length;
|
||||
const limit = indent.length + trimmed.length;
|
||||
|
||||
const symbolRanges: [number, number][] = [];
|
||||
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)]);
|
||||
}
|
||||
|
||||
return { text: indent + trimmed, symbolRanges };
|
||||
}
|
||||
|
||||
export function renderDocument(results: ReferenceResults): RenderedDocument {
|
||||
const prefix = commentPrefix(results.languageId);
|
||||
const out: string[] = [];
|
||||
const lines: ResultLine[] = [];
|
||||
const push = (text: string, meta: ResultLine) => {
|
||||
out.push(text);
|
||||
lines.push(meta);
|
||||
};
|
||||
|
||||
const files = results.files;
|
||||
const total = results.total;
|
||||
push(`${prefix} ${total} reference${total === 1 ? '' : 's'} to '${results.symbol}' ` +
|
||||
`in ${files.length} file${files.length === 1 ? '' : 's'}`, { kind: 'title' });
|
||||
push(`${prefix} Enter / F12 / Ctrl+Click: go to reference F5: refresh`, { kind: 'title' });
|
||||
|
||||
for (const file of files) {
|
||||
const projectLabel = file.project ? `[${file.project}] ` : '';
|
||||
push('', { kind: 'blank' });
|
||||
push(`${prefix} ${projectLabel}${relativePath(file.uri)} (${file.count})`,
|
||||
{ kind: 'header', file: file.uri });
|
||||
|
||||
for (const source of file.lines) {
|
||||
const { text, symbolRanges } = displayLine(source, CODE_INDENT);
|
||||
push(text, {
|
||||
kind: 'code',
|
||||
file: file.uri,
|
||||
sourceLine: source.line,
|
||||
sourceCol: source.ranges[0].start.character,
|
||||
symbolRanges,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { content: out.join('\n'), lines };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Containing member (webview panel only — one symbol request per file)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AnySymbol = vscode.DocumentSymbol & vscode.SymbolInformation;
|
||||
|
||||
const symbolCache = new Map<string, Promise<vscode.DocumentSymbol[]>>();
|
||||
|
||||
function timeout<T>(promise: Thenable<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise<T>(resolve => setTimeout(() => resolve(fallback), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Document symbols as a tree, normalising servers that answer with SymbolInformation[]. */
|
||||
function documentSymbols(uri: vscode.Uri): Promise<vscode.DocumentSymbol[]> {
|
||||
const key = uri.toString();
|
||||
const cached = symbolCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const promise = (async () => {
|
||||
const raw = await timeout(
|
||||
vscode.commands.executeCommand<AnySymbol[]>('vscode.executeDocumentSymbolProvider', uri),
|
||||
5000, undefined as unknown as AnySymbol[]);
|
||||
if (!raw || raw.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (raw[0].children !== undefined) {
|
||||
return raw as vscode.DocumentSymbol[];
|
||||
}
|
||||
// Flat SymbolInformation[]: rebuild a shallow tree by containment.
|
||||
const flat = raw
|
||||
.filter(s => s.location)
|
||||
.map(s => new vscode.DocumentSymbol(s.name, '', s.kind, s.location.range, s.location.range));
|
||||
flat.sort((a, b) => a.range.start.compareTo(b.range.start) ||
|
||||
b.range.end.compareTo(a.range.end));
|
||||
const roots: vscode.DocumentSymbol[] = [];
|
||||
const stack: vscode.DocumentSymbol[] = [];
|
||||
for (const symbol of flat) {
|
||||
while (stack.length > 0 && !stack[stack.length - 1].range.contains(symbol.range)) {
|
||||
stack.pop();
|
||||
}
|
||||
(stack.length > 0 ? stack[stack.length - 1].children : roots).push(symbol);
|
||||
stack.push(symbol);
|
||||
}
|
||||
return roots;
|
||||
})();
|
||||
symbolCache.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
const SKIPPED_KINDS = new Set([vscode.SymbolKind.Namespace, vscode.SymbolKind.Module, vscode.SymbolKind.Package]);
|
||||
|
||||
/** Dotted path of the innermost symbols containing `line`, e.g. `Profiler.BeginSample`. */
|
||||
function memberPath(symbols: vscode.DocumentSymbol[], line: number): string {
|
||||
const names: string[] = [];
|
||||
let level = symbols;
|
||||
for (;;) {
|
||||
const hit = level.find(s => s.range.start.line <= line && line <= s.range.end.line);
|
||||
if (!hit) {
|
||||
break;
|
||||
}
|
||||
if (!SKIPPED_KINDS.has(hit.kind)) {
|
||||
names.push(hit.name);
|
||||
}
|
||||
level = hit.children ?? [];
|
||||
}
|
||||
return names.join('.');
|
||||
}
|
||||
|
||||
/** Resolves the containing member for every referenced line, per file. */
|
||||
export async function containingMembers(results: ReferenceResults): Promise<Map<string, string>> {
|
||||
const members = new Map<string, string>();
|
||||
await Promise.all(results.files.map(async file => {
|
||||
const symbols = await documentSymbols(file.uri);
|
||||
if (symbols.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const source of file.lines) {
|
||||
members.set(`${file.uri.toString()}|${source.line}`, memberPath(symbols, source.line));
|
||||
}
|
||||
}));
|
||||
return members;
|
||||
}
|
||||
|
||||
/** Symbol information is cached per file; drop it when the file changes. */
|
||||
export function invalidateSymbols(uri: vscode.Uri): void {
|
||||
symbolCache.delete(uri.toString());
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2022"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules", ".vscode-test"]
|
||||
}
|
||||
Reference in New Issue
Block a user