Dock the results table in the bottom panel

createWebviewPanel only ever lives in the editor area, so the table could
not be dragged next to Terminal / Problems. A WebviewView can, and it can
also be dragged to either side bar.

- Extract ResultsView: the table's html, messaging, row building and
  navigation, independent of what hosts the webview. ReferencePanel keeps
  hosting it in an editor group; ReferenceViewProvider hosts it in a
  contributed panel view container. Results that arrive before the docked
  view has been resolved are queued and applied on resolve.
- coloredReferences.panelLocation selects the placement: bottom (default),
  beside, or below. "below" makes the editor row first so the table is wide
  and short, which suits the column layout better than a tall narrow group.
- sourceColumn() now ignores editors with no view column, so opening a
  reference from the docked view lands in a real editor group.
- toggleView and the refresh button work from either host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
max
2026-09-07 16:23:02 +02:00
co-authored by Claude Opus 5
parent 2398b6b2ce
commit 92ff02f7ff
5 changed files with 312 additions and 81 deletions
+6 -1
View File
@@ -8,7 +8,9 @@ source file*, so your theme's grammar colors every line for free. File headers s
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.
and Containing member — grouped by file, with a filter box. By default it docks in the **bottom panel**
alongside Terminal and Problems, where a wide, short table reads best; you can drag it to either side bar,
or set `coloredReferences.panelLocation` to put it in an editor group instead.
Works with any language server that implements references: DotRush, C# Dev Kit, OmniSharp, TypeScript, Rust, Go, ...
@@ -29,6 +31,9 @@ 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.panelLocation` — where the panel opens: `bottom` (default, docked next to Terminal /
Problems and draggable to a side bar), `beside` (editor group to the side), or `below` (editor group
underneath, so the table is wide and short)
- `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`)
+39 -3
View File
@@ -18,7 +18,9 @@
"dotnet",
"visual studio"
],
"activationEvents": [],
"activationEvents": [
"onView:coloredReferences.resultsView"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
@@ -75,7 +77,7 @@
"command": "coloredReferences.toggleView",
"key": "ctrl+alt+shift+f12",
"mac": "cmd+alt+shift+f12",
"when": "resourceScheme == colored-refs || activeWebviewPanelId == coloredReferences.results"
"when": "resourceScheme == colored-refs || activeWebviewPanelId == coloredReferences.results || view == coloredReferences.resultsView"
}
],
"menus": {
@@ -109,7 +111,7 @@
},
{
"command": "coloredReferences.toggleView",
"when": "resourceScheme == colored-refs || activeWebviewPanelId == coloredReferences.results"
"when": "resourceScheme == colored-refs || activeWebviewPanelId == coloredReferences.results || view == coloredReferences.resultsView"
}
]
},
@@ -143,8 +145,42 @@
],
"default": "document",
"description": "Which view Find All References (Colored) opens."
},
"coloredReferences.panelLocation": {
"type": "string",
"enum": [
"bottom",
"beside",
"below"
],
"enumDescriptions": [
"Dock the results table in the bottom panel, beside Terminal and Problems. Drag it to a side bar if you prefer.",
"Open the results table in an editor group beside the current one.",
"Open the results table in an editor group below the current one, so it is wide and short."
],
"default": "bottom",
"description": "Where the results panel opens."
}
}
},
"viewsContainers": {
"panel": [
{
"id": "coloredReferences",
"title": "References",
"icon": "media/references-dark.svg"
}
]
},
"views": {
"coloredReferences": [
{
"id": "coloredReferences.resultsView",
"name": "References",
"type": "webview",
"contextualTitle": "References"
}
]
}
},
"scripts": {
+11 -8
View File
@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { PanelManager, ReferencePanel } from './panel';
import { PanelManager, Placement, ResultsView } from './panel';
import {
CODE_INDENT, Origin, ReferenceResults, RenderedDocument, ResultLine, SCHEME,
findReferences, gather, invalidateSymbols, relativePath, renderDocument,
@@ -199,21 +199,23 @@ async function showDocument(results: ReferenceResults, targetUri?: vscode.Uri):
applyDecorations(editor);
}
async function showPanel(results: ReferenceResults, target?: ReferencePanel): Promise<void> {
async function showPanel(results: ReferenceResults, target?: ResultsView): 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);
await panels.show(
results,
cfg.get<boolean>('reuseTab', true),
cfg.get<Placement>('panelLocation', 'bottom'));
}
interface SearchTarget {
/** Refresh an existing results document in place. */
uri?: vscode.Uri;
/** Refresh an existing panel in place. */
panel?: ReferencePanel;
/** Refresh an existing results table in place. */
panel?: ResultsView;
/** Which view to open for a new search. */
view?: View;
}
@@ -318,7 +320,7 @@ async function refreshCommand(): Promise<void> {
await runSearch(origin, languageId, symbol, { uri: editor.document.uri });
}
function refreshPanel(panel: ReferencePanel): void {
function refreshPanel(panel: ResultsView): void {
const origin = panel.origin;
if (!origin) {
return;
@@ -336,7 +338,7 @@ async function toggleViewCommand(): Promise<void> {
await showPanel(stored.results);
return;
}
// Coming from a panel: the panel is the active tab, so there is no active editor.
// Coming from the table: it holds the focus, so there is no active editor.
const active = panels.active;
if (active?.shown) {
await showDocument(active.shown);
@@ -374,6 +376,7 @@ export function activate(context: vscode.ExtensionContext): void {
};
context.subscriptions.push(
panels.register(),
vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider),
vscode.languages.registerDefinitionProvider({ scheme: SCHEME }, definitionProvider),
vscode.languages.registerHoverProvider({ scheme: SCHEME }, hoverProvider),
+191 -65
View File
@@ -4,6 +4,9 @@ import {
ReferenceResults, containingMembers, displayLine, relativePath,
} from './references';
/** Where the results table is shown. */
export type Placement = 'bottom' | 'beside' | 'below';
/** One reference, as shown in a table row. */
export interface PanelRow {
id: number;
@@ -74,52 +77,35 @@ function nonce(): string {
}
/**
* A results panel: the same references as the virtual document, as a table with
* resizable, sortable columns plus a containing-member column.
* What `ResultsView` needs from whatever is hosting the webview — an editor-area
* `WebviewPanel` or a `WebviewView` docked in the bottom panel / a side bar.
*/
export class ReferencePanel {
static readonly viewType = 'coloredReferences.results';
interface ViewHost {
readonly webview: vscode.Webview;
setTitle(title: string): void;
/** The editor group the host occupies, or undefined when it is not in the editor grid. */
readonly viewColumn: vscode.ViewColumn | undefined;
}
private readonly panel: vscode.WebviewPanel;
private readonly disposables: vscode.Disposable[] = [];
/**
* The results table: the same references as the virtual document, with resizable,
* sortable columns plus a containing-member column. Host-agnostic.
*/
export class ResultsView {
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;
private readonly listener: vscode.Disposable;
constructor(
private readonly extensionUri: vscode.Uri,
column: vscode.ViewColumn,
private readonly onRefresh: (panel: ReferencePanel) => void,
private readonly onDispose: (panel: ReferencePanel) => void,
private readonly host: ViewHost,
private readonly onRefresh: (view: ResultsView) => 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;
this.host.webview.html = this.html();
this.listener = this.host.webview.onDidReceiveMessage(message => void this.receive(message));
}
get origin(): ReferenceResults['origin'] | undefined {
@@ -138,17 +124,9 @@ export class ReferencePanel {
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}`;
this.host.setTitle(`References to ${results.symbol}`);
const members = await containingMembers(results);
const { rows, locations } = buildRows(results, members);
@@ -165,7 +143,7 @@ export class ReferencePanel {
private post(message: unknown): void {
if (this.ready) {
void this.panel.webview.postMessage(message);
void this.host.webview.postMessage(message);
} else {
// The webview asks for its results as soon as its script runs.
this.pending = message;
@@ -178,7 +156,7 @@ export class ReferencePanel {
case 'ready':
this.ready = true;
if (this.pending) {
void this.panel.webview.postMessage(this.pending);
void this.host.webview.postMessage(this.pending);
this.pending = undefined;
}
return;
@@ -192,9 +170,10 @@ export class ReferencePanel {
}
}
/** The group a source file should open in: never the one holding this panel. */
/** The group a source file should open in: never the one holding this view. */
private sourceColumn(): vscode.ViewColumn {
const editors = vscode.window.visibleTextEditors.filter(e => e.viewColumn !== this.panel.viewColumn);
const editors = vscode.window.visibleTextEditors.filter(
e => e.viewColumn !== undefined && e.viewColumn !== this.host.viewColumn);
const origin = editors.find(e => e.document.uri.toString() === this.results?.origin.uri.toString());
return origin?.viewColumn ?? editors[0]?.viewColumn ?? vscode.ViewColumn.Beside;
}
@@ -226,7 +205,7 @@ export class ReferencePanel {
}
private html(): string {
const webview = this.panel.webview;
const webview = this.host.webview;
const asset = (name: string) =>
webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'media', name));
const token = nonce();
@@ -261,38 +240,181 @@ export class ReferencePanel {
</html>`;
}
dispose(): void {
this.listener.dispose();
}
}
function webviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {
return {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'media')],
};
}
/** The results table in an editor group. */
export class ReferencePanel {
static readonly viewType = 'coloredReferences.results';
private readonly panel: vscode.WebviewPanel;
private readonly subscription: vscode.Disposable;
readonly view: ResultsView;
constructor(
extensionUri: vscode.Uri,
column: vscode.ViewColumn,
onRefresh: (view: ResultsView) => void,
private readonly onDispose: (panel: ReferencePanel) => void,
) {
const panel = vscode.window.createWebviewPanel(
ReferencePanel.viewType,
'References',
{ viewColumn: column, preserveFocus: false },
{
...webviewOptions(extensionUri),
// Ctrl+F focuses the panel's own filter box instead.
enableFindWidget: false,
retainContextWhenHidden: true,
},
);
panel.iconPath = {
light: vscode.Uri.joinPath(extensionUri, 'media', 'references-light.svg'),
dark: vscode.Uri.joinPath(extensionUri, 'media', 'references-dark.svg'),
};
this.panel = panel;
this.view = new ResultsView(extensionUri, {
webview: panel.webview,
setTitle: title => { panel.title = title; },
// Read live: the user can drag the panel to another group.
get viewColumn() { return panel.viewColumn; },
}, onRefresh);
this.subscription = panel.onDidDispose(() => this.dispose());
}
get isActive(): boolean {
return this.panel.active;
}
reveal(): void {
this.panel.reveal(this.panel.viewColumn, false);
}
dispose(): void {
this.onDispose(this);
for (const disposable of this.disposables.splice(0)) {
disposable.dispose();
}
this.subscription.dispose();
this.view.dispose();
this.panel.dispose();
}
}
/** Owns the open panels and decides whether a search reuses one. */
export class PanelManager {
private readonly panels: ReferencePanel[] = [];
/** The results table docked in the bottom panel (draggable to a side bar). */
export class ReferenceViewProvider implements vscode.WebviewViewProvider {
static readonly viewId = 'coloredReferences.resultsView';
private view: ResultsView | undefined;
private webviewView: vscode.WebviewView | undefined;
/** Results that arrived before the view was resolved. */
private queued: ReferenceResults | undefined;
constructor(
private readonly extensionUri: vscode.Uri,
private readonly refresh: (panel: ReferencePanel) => void,
private readonly onRefresh: (view: ResultsView) => 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];
get current(): ResultsView | undefined {
return this.view;
}
/** 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);
resolveWebviewView(webviewView: vscode.WebviewView): void {
webviewView.webview.options = webviewOptions(this.extensionUri);
this.webviewView = webviewView;
this.view = new ResultsView(this.extensionUri, {
webview: webviewView.webview,
setTitle: title => { webviewView.title = title; },
viewColumn: undefined,
}, this.onRefresh);
webviewView.onDidDispose(() => {
this.view?.dispose();
this.view = undefined;
this.webviewView = undefined;
});
if (this.queued) {
void this.view.update(this.queued);
this.queued = undefined;
}
}
/** Reveals the view, resolving it first if the panel has never been opened. */
async show(results: ReferenceResults): Promise<ResultsView | undefined> {
if (!this.view) {
this.queued = results;
// Focusing the view makes the workbench open the container and resolve it,
// which then applies the queued results.
await vscode.commands.executeCommand(`${ReferenceViewProvider.viewId}.focus`);
return this.view;
}
await this.view.update(results);
this.webviewView?.show(true);
return this.view;
}
dispose(): void {
this.view?.dispose();
this.view = undefined;
}
}
/** Owns the open results views and decides whether a search reuses one. */
export class PanelManager {
private readonly panels: ReferencePanel[] = [];
private readonly bottom: ReferenceViewProvider;
/** The view the last search rendered into. */
private lastShown: ResultsView | undefined;
constructor(
private readonly extensionUri: vscode.Uri,
private readonly refresh: (view: ResultsView) => void,
) {
this.bottom = new ReferenceViewProvider(extensionUri, refresh);
}
register(): vscode.Disposable {
return vscode.window.registerWebviewViewProvider(
ReferenceViewProvider.viewId, this.bottom,
{ webviewOptions: { retainContextWhenHidden: true } });
}
/** The focused results view, falling back to the one shown most recently. */
get active(): ResultsView | undefined {
const focused = this.panels.find(p => p.isActive);
return focused?.view ?? this.lastShown ?? this.bottom.current;
}
/** Shows `results` in `placement`, reusing an existing view when `reuse` is set. */
async show(results: ReferenceResults, reuse: boolean, placement: Placement): Promise<void> {
if (placement === 'bottom') {
this.lastShown = await this.bottom.show(results) ?? this.lastShown;
return;
}
const existing = reuse ? this.panels[this.panels.length - 1] : undefined;
const panel = existing ?? await this.create(placement);
await panel.view.update(results);
panel.reveal();
return panel;
this.lastShown = panel.view;
}
private create(column: vscode.ViewColumn): ReferencePanel {
private async create(placement: Placement): Promise<ReferencePanel> {
if (placement === 'below') {
// ViewColumn only addresses columns, so make the row first and open into it.
await vscode.commands.executeCommand('workbench.action.newGroupBelow');
}
const column = placement === 'below' ? vscode.ViewColumn.Active : vscode.ViewColumn.Beside;
const panel = new ReferencePanel(
this.extensionUri, column, this.refresh,
closed => {
@@ -300,6 +422,9 @@ export class PanelManager {
if (index >= 0) {
this.panels.splice(index, 1);
}
if (this.lastShown === closed.view) {
this.lastShown = undefined;
}
});
this.panels.push(panel);
return panel;
@@ -309,5 +434,6 @@ export class PanelManager {
for (const panel of this.panels.splice(0)) {
panel.dispose();
}
this.bottom.dispose();
}
}
+65 -4
View File
@@ -57,6 +57,20 @@ async function search(relativeFile: string, declaration: string, symbol: string)
return { results, position, uri };
}
function coloredEditorTabs(): vscode.Tab[] {
return 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';
});
}
async function setPlacement(placement: string): Promise<void> {
await vscode.workspace.getConfiguration('coloredReferences')
.update('panelLocation', placement, vscode.ConfigurationTarget.Workspace);
assert.strictEqual(
vscode.workspace.getConfiguration('coloredReferences').get('panelLocation'), placement);
}
suite('Results panel', () => {
let results: ReferenceResults;
@@ -64,6 +78,8 @@ suite('Results panel', () => {
const ext = vscode.extensions.getExtension('local.colored-references');
assert.ok(ext);
await ext.activate();
// These tests cover the editor-area placement; the docked view has its own suite.
await setPlacement('beside');
results = (await search(TARGET_FILE, TARGET_DECL, TARGET_SYMBOL)).results;
});
@@ -138,12 +154,57 @@ suite('Results panel', () => {
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';
});
const editorTabs = coloredEditorTabs();
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}`);
});
});
suite('Results panel docked in the bottom panel', () => {
suiteSetup(async () => {
const ext = vscode.extensions.getExtension('local.colored-references');
assert.ok(ext);
await ext.activate();
await setPlacement('bottom');
// Close the editor-area panels the previous suite left behind.
for (const tab of panelTabs()) {
await vscode.window.tabGroups.close(tab, true);
}
for (const tab of coloredEditorTabs()) {
await vscode.window.tabGroups.close(tab, true);
}
await sleep(500);
});
test('the docked view is contributed and focusable', async () => {
const commands = await vscode.commands.getCommands(true);
assert.ok(commands.includes(`${PANEL_VIEW_TYPE}View.focus`) ||
commands.includes('coloredReferences.resultsView.focus'),
'the webview view did not contribute a focus command');
});
test('a search fills the docked view instead of opening an editor tab', async () => {
await search(TARGET_FILE, TARGET_DECL, TARGET_SYMBOL);
await vscode.commands.executeCommand('coloredReferences.findInPanel');
await sleep(2000);
assert.strictEqual(panelTabs().length, 0,
'panelLocation is "bottom" but a webview opened in the editor area');
assert.strictEqual(coloredEditorTabs().length, 0,
'no results document should have been opened');
});
test('toggleView reaches the results held by the docked view', async () => {
// Only possible if the docked view actually received the results.
await vscode.commands.executeCommand('coloredReferences.toggleView');
await sleep(1500);
const editorTabs = coloredEditorTabs();
assert.strictEqual(editorTabs.length, 1,
'toggleView could not find the results shown in the docked view');
assert.ok(editorTabs[0].label.startsWith(`References to ${TARGET_SYMBOL}`),
`unexpected tab label: ${editorTabs[0].label}`);
});
});