diff --git a/README.md b/README.md index 21eca3f..1b1c14e 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/package.json b/package.json index d215cad..e05dc3b 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/extension.ts b/src/extension.ts index 5aded56..d3fc8c8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 { +async function showPanel(results: ReferenceResults, target?: ResultsView): Promise { if (target) { await target.update(results); return; } const cfg = config(); - const column = cfg.get('openBeside', true) ? vscode.ViewColumn.Beside : vscode.ViewColumn.Active; - await panels.show(results, cfg.get('reuseTab', true), column); + await panels.show( + results, + cfg.get('reuseTab', true), + cfg.get('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 { 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 { 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), diff --git a/src/panel.ts b/src/panel.ts index d259f95..caf2521 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -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(); 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 { 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 { `; } + 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 { - 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 { + 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 { + 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 { + 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(); } } diff --git a/src/test/suite/panel.test.ts b/src/test/suite/panel.test.ts index 24c947a..653092f 100644 --- a/src/test/suite/panel.test.ts +++ b/src/test/suite/panel.test.ts @@ -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 { + 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}`); + }); +});