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
+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}`);
});
});