diff --git a/extensions/ql-vscode/src/abstract-interface-manager.ts b/extensions/ql-vscode/src/abstract-interface-manager.ts new file mode 100644 index 000000000..1dad018bf --- /dev/null +++ b/extensions/ql-vscode/src/abstract-interface-manager.ts @@ -0,0 +1,119 @@ +import { + WebviewPanel, + ExtensionContext, + window as Window, + ViewColumn, + Uri, + WebviewPanelOptions, + WebviewOptions +} from 'vscode'; +import * as path from 'path'; + +import { DisposableObject } from './pure/disposable-object'; +import { tmpDir } from './helpers'; +import { getHtmlForWebview, WebviewMessage, WebviewView } from './interface-utils'; + +export type InterfacePanelConfig = { + viewId: string; + title: string; + viewColumn: ViewColumn; + view: WebviewView; + preserveFocus?: boolean; + additionalOptions?: WebviewPanelOptions & WebviewOptions; +} + +export abstract class AbstractInterfaceManager extends DisposableObject { + protected panel: WebviewPanel | undefined; + protected panelLoaded = false; + protected panelLoadedCallBacks: (() => void)[] = []; + + constructor( + protected readonly ctx: ExtensionContext + ) { + super(); + } + + protected get isShowingPanel() { + return !!this.panel; + } + + protected getPanel(): WebviewPanel { + if (this.panel == undefined) { + const { ctx } = this; + + const config = this.getPanelConfig(); + + this.panel = Window.createWebviewPanel( + config.viewId, + config.title, + { viewColumn: ViewColumn.Active, preserveFocus: true }, + { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + ...config.additionalOptions, + localResourceRoots: [ + ...(config.additionalOptions?.localResourceRoots ?? []), + Uri.file(tmpDir.name), + Uri.file(path.join(ctx.extensionPath, 'out')), + Uri.file(path.join(ctx.extensionPath, 'node_modules/@vscode/codicons/dist')), + ], + } + ); + this.push( + this.panel.onDidDispose( + () => { + this.panel = undefined; + this.panelLoaded = false; + this.onPanelDispose(); + }, + null, + ctx.subscriptions + ) + ); + + this.panel.webview.html = getHtmlForWebview( + ctx, + this.panel.webview, + config.view, + { + allowInlineStyles: true, + } + ); + this.push( + this.panel.webview.onDidReceiveMessage( + async (e) => this.onMessage(e), + undefined, + ctx.subscriptions + ) + ); + } + return this.panel; + } + + protected abstract getPanelConfig(): InterfacePanelConfig; + + protected abstract onPanelDispose(): void; + + protected abstract onMessage(msg: FromMessage): Promise; + + protected waitForPanelLoaded(): Promise { + return new Promise((resolve) => { + if (this.panelLoaded) { + resolve(); + } else { + this.panelLoadedCallBacks.push(resolve); + } + }); + } + + protected onWebViewLoaded(): void { + this.panelLoaded = true; + this.panelLoadedCallBacks.forEach((cb) => cb()); + this.panelLoadedCallBacks = []; + } + + protected postMessage(msg: ToMessage): Thenable { + return this.getPanel().webview.postMessage(msg); + } +} diff --git a/extensions/ql-vscode/src/compare/compare-interface.ts b/extensions/ql-vscode/src/compare/compare-interface.ts index 76d59e5cb..0a0b9b6b2 100644 --- a/extensions/ql-vscode/src/compare/compare-interface.ts +++ b/extensions/ql-vscode/src/compare/compare-interface.ts @@ -1,14 +1,8 @@ -import { DisposableObject } from '../pure/disposable-object'; import { - WebviewPanel, ExtensionContext, - window as Window, ViewColumn, - Uri, } from 'vscode'; -import * as path from 'path'; -import { tmpDir } from '../helpers'; import { FromCompareViewMessage, ToCompareViewMessage, @@ -17,26 +11,24 @@ import { import { Logger } from '../logging'; import { CodeQLCliServer } from '../cli'; import { DatabaseManager } from '../databases'; -import { getHtmlForWebview, jumpToLocation } from '../interface-utils'; +import { jumpToLocation } from '../interface-utils'; import { transformBqrsResultSet, RawResultSet, BQRSInfo } from '../pure/bqrs-cli-types'; import resultsDiff from './resultsDiff'; import { CompletedLocalQueryInfo } from '../query-results'; import { getErrorMessage } from '../pure/helpers-pure'; import { HistoryItemLabelProvider } from '../history-item-label-provider'; +import { AbstractInterfaceManager, InterfacePanelConfig } from '../abstract-interface-manager'; interface ComparePair { from: CompletedLocalQueryInfo; to: CompletedLocalQueryInfo; } -export class CompareInterfaceManager extends DisposableObject { +export class CompareInterfaceManager extends AbstractInterfaceManager { private comparePair: ComparePair | undefined; - private panel: WebviewPanel | undefined; - private panelLoaded = false; - private panelLoadedCallBacks: (() => void)[] = []; constructor( - private ctx: ExtensionContext, + ctx: ExtensionContext, private databaseManager: DatabaseManager, private cliServer: CodeQLCliServer, private logger: Logger, @@ -45,7 +37,7 @@ export class CompareInterfaceManager extends DisposableObject { item: CompletedLocalQueryInfo ) => Promise ) { - super(); + super(ctx); } async showResults( @@ -103,64 +95,24 @@ export class CompareInterfaceManager extends DisposableObject { } } - getPanel(): WebviewPanel { - if (this.panel == undefined) { - const { ctx } = this; - const panel = (this.panel = Window.createWebviewPanel( - 'compareView', - 'Compare CodeQL Query Results', - { viewColumn: ViewColumn.Active, preserveFocus: true }, - { - enableScripts: true, - enableFindWidget: true, - retainContextWhenHidden: true, - localResourceRoots: [ - Uri.file(tmpDir.name), - Uri.file(path.join(this.ctx.extensionPath, 'out')), - ], - } - )); - this.push(this.panel.onDidDispose( - () => { - this.panel = undefined; - this.comparePair = undefined; - }, - null, - ctx.subscriptions - )); - - panel.webview.html = getHtmlForWebview( - ctx, - panel.webview, - 'compare' - ); - this.push(panel.webview.onDidReceiveMessage( - async (e) => this.handleMsgFromView(e), - undefined, - ctx.subscriptions - )); - } - return this.panel; + protected getPanelConfig(): InterfacePanelConfig { + return { + viewId: 'compareView', + title: 'Compare CodeQL Query Results', + viewColumn: ViewColumn.Active, + preserveFocus: true, + view: 'compare', + }; } - private waitForPanelLoaded(): Promise { - return new Promise((resolve) => { - if (this.panelLoaded) { - resolve(); - } else { - this.panelLoadedCallBacks.push(resolve); - } - }); + protected onPanelDispose(): void { + this.comparePair = undefined; } - private async handleMsgFromView( - msg: FromCompareViewMessage - ): Promise { + protected async onMessage(msg: FromCompareViewMessage): Promise { switch (msg.t) { case 'compareViewLoaded': - this.panelLoaded = true; - this.panelLoadedCallBacks.forEach((cb) => cb()); - this.panelLoadedCallBacks = []; + this.onWebViewLoaded(); break; case 'changeCompare': @@ -177,10 +129,6 @@ export class CompareInterfaceManager extends DisposableObject { } } - private postMessage(msg: ToCompareViewMessage): Thenable { - return this.getPanel().webview.postMessage(msg); - } - private async findCommonResultSetNames( from: CompletedLocalQueryInfo, to: CompletedLocalQueryInfo, diff --git a/extensions/ql-vscode/src/interface-utils.ts b/extensions/ql-vscode/src/interface-utils.ts index 152cc68b1..c112046bc 100644 --- a/extensions/ql-vscode/src/interface-utils.ts +++ b/extensions/ql-vscode/src/interface-utils.ts @@ -112,6 +112,12 @@ export function tryResolveLocation( } } +export type WebviewView = 'results' | 'compare' | 'remote-queries'; + +export interface WebviewMessage { + t: string; +} + /** * Returns HTML to populate the given webview. * Uses a content security policy that only loads the given script. @@ -119,21 +125,28 @@ export function tryResolveLocation( export function getHtmlForWebview( ctx: ExtensionContext, webview: Webview, - view: 'results' | 'compare' | 'remote-queries', + view: WebviewView, { - allowInlineStyles + allowInlineStyles, }: { allowInlineStyles?: boolean; } = { - allowInlineStyles: false + allowInlineStyles: false, } ): string { const scriptUriOnDisk = Uri.file( ctx.asAbsolutePath('out/webview.js') ); + // Allows use of the VS Code "codicons" icon set. + // See https://github.com/microsoft/vscode-codicons + const codiconsPathOnDisk = Uri.file( + ctx.asAbsolutePath('node_modules/@vscode/codicons/dist/codicon.css') + ); + const stylesheetUrisOnDisk = [ - Uri.file(ctx.asAbsolutePath('out/webview.css')) + Uri.file(ctx.asAbsolutePath('out/webview.css')), + codiconsPathOnDisk ]; // Convert the on-disk URIs into webview URIs. diff --git a/extensions/ql-vscode/src/interface.ts b/extensions/ql-vscode/src/interface.ts index a8c1acb31..efc8f85e5 100644 --- a/extensions/ql-vscode/src/interface.ts +++ b/extensions/ql-vscode/src/interface.ts @@ -1,6 +1,4 @@ -import * as path from 'path'; import * as Sarif from 'sarif'; -import { DisposableObject } from './pure/disposable-object'; import * as vscode from 'vscode'; import { Diagnostic, @@ -14,7 +12,7 @@ import { import * as cli from './cli'; import { CodeQLCliServer } from './cli'; import { DatabaseEventKind, DatabaseItem, DatabaseManager } from './databases'; -import { showAndLogErrorMessage, tmpDir } from './helpers'; +import { showAndLogErrorMessage } from './helpers'; import { assertNever, getErrorMessage, getErrorStack } from './pure/helpers-pure'; import { FromResultsViewMsg, @@ -40,13 +38,13 @@ import { WebviewReveal, fileUriToWebviewUri, tryResolveLocation, - getHtmlForWebview, shownLocationDecoration, shownLocationLineDecoration, jumpToLocation, } from './interface-utils'; import { getDefaultResultSetName, ParsedResultSets } from './pure/interface-types'; import { RawResultSet, transformBqrsResultSet, ResultSetSchema } from './pure/bqrs-cli-types'; +import { AbstractInterfaceManager, InterfacePanelConfig } from './abstract-interface-manager'; import { PAGE_SIZE } from './config'; import { CompletedLocalQueryInfo } from './query-results'; import { HistoryItemLabelProvider } from './history-item-label-provider'; @@ -122,12 +120,9 @@ function numInterpretedPages(interpretation: Interpretation | undefined): number return Math.ceil(n / pageSize); } -export class InterfaceManager extends DisposableObject { +export class InterfaceManager extends AbstractInterfaceManager { private _displayedQuery?: CompletedLocalQueryInfo; private _interpretation?: Interpretation; - private _panel: vscode.WebviewPanel | undefined; - private _panelLoaded = false; - private _panelLoadedCallBacks: (() => void)[] = []; private readonly _diagnosticCollection = languages.createDiagnosticCollection( 'codeql-query-results' @@ -140,7 +135,7 @@ export class InterfaceManager extends DisposableObject { public logger: Logger, private labelProvider: HistoryItemLabelProvider ) { - super(); + super(ctx); this.push(this._diagnosticCollection); this.push( vscode.window.onDidChangeTextEditorSelection( @@ -165,7 +160,7 @@ export class InterfaceManager extends DisposableObject { this.databaseManager.onDidChangeDatabaseItem(({ kind }) => { if (kind === DatabaseEventKind.Remove) { this._diagnosticCollection.clear(); - if (this.isShowingPanel()) { + if (this.isShowingPanel) { void this.postMessage({ t: 'untoggleShowProblems' }); @@ -179,52 +174,81 @@ export class InterfaceManager extends DisposableObject { await this.postMessage({ t: 'navigatePath', direction }); } - private isShowingPanel() { - return !!this._panel; + protected getPanelConfig(): InterfacePanelConfig { + return { + viewId: 'resultsView', + title: 'CodeQL Query Results', + viewColumn: this.chooseColumnForWebview(), + preserveFocus: true, + view: 'results', + }; } - // Returns the webview panel, creating it if it doesn't already - // exist. - getPanel(): vscode.WebviewPanel { - if (this._panel == undefined) { - const { ctx } = this; - const webViewColumn = this.chooseColumnForWebview(); - const panel = (this._panel = Window.createWebviewPanel( - 'resultsView', // internal name - 'CodeQL Query Results', // user-visible name - { viewColumn: webViewColumn, preserveFocus: true }, - { - enableScripts: true, - enableFindWidget: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.file(tmpDir.name), - vscode.Uri.file(path.join(this.ctx.extensionPath, 'out')) - ] - } - )); + protected onPanelDispose(): void { + this._displayedQuery = undefined; + } - this.push(this._panel.onDidDispose( - () => { - this._panel = undefined; - this._displayedQuery = undefined; - this._panelLoaded = false; - }, - null, - ctx.subscriptions - )); - panel.webview.html = getHtmlForWebview( - ctx, - panel.webview, - 'results' - ); - this.push(panel.webview.onDidReceiveMessage( - async (e) => this.handleMsgFromView(e), - undefined, - ctx.subscriptions - )); + protected async onMessage(msg: FromResultsViewMsg): Promise { + try { + switch (msg.t) { + case 'resultViewLoaded': + this.onWebViewLoaded(); + break; + case 'viewSourceFile': { + await jumpToLocation(msg, this.databaseManager, this.logger); + break; + } + case 'toggleDiagnostics': { + if (msg.visible) { + const databaseItem = this.databaseManager.findDatabaseItem( + Uri.parse(msg.databaseUri) + ); + if (databaseItem !== undefined) { + await this.showResultsAsDiagnostics( + msg.origResultsPaths, + msg.metadata, + databaseItem + ); + } + } else { + // TODO: Only clear diagnostics on the same database. + this._diagnosticCollection.clear(); + } + break; + } + case 'changeSort': + await this.changeRawSortState(msg.resultSetName, msg.sortState); + break; + case 'changeInterpretedSort': + await this.changeInterpretedSortState(msg.sortState); + break; + case 'changePage': + if (msg.selectedTable === ALERTS_TABLE_NAME || msg.selectedTable === GRAPH_TABLE_NAME) { + await this.showPageOfInterpretedResults(msg.pageNumber); + } + else { + await this.showPageOfRawResults( + msg.selectedTable, + msg.pageNumber, + // When we are in an unsorted state, we guarantee that + // sortedResultsInfo doesn't have an entry for the current + // result set. Use this to determine whether or not we use + // the sorted bqrs file. + !!this._displayedQuery?.completedQuery.sortedResultsInfo[msg.selectedTable] + ); + } + break; + case 'openFile': + await this.openFile(msg.filePath); + break; + default: + assertNever(msg); + } + } catch (e) { + void showAndLogErrorMessage(getErrorMessage(e), { + fullMessage: getErrorStack(e) + }); } - return this._panel; } /** @@ -289,85 +313,6 @@ export class InterfaceManager extends DisposableObject { await this.showPageOfRawResults(resultSetName, 0, true); } - private async handleMsgFromView(msg: FromResultsViewMsg): Promise { - try { - switch (msg.t) { - case 'viewSourceFile': { - await jumpToLocation(msg, this.databaseManager, this.logger); - break; - } - case 'toggleDiagnostics': { - if (msg.visible) { - const databaseItem = this.databaseManager.findDatabaseItem( - Uri.parse(msg.databaseUri) - ); - if (databaseItem !== undefined) { - await this.showResultsAsDiagnostics( - msg.origResultsPaths, - msg.metadata, - databaseItem - ); - } - } else { - // TODO: Only clear diagnostics on the same database. - this._diagnosticCollection.clear(); - } - break; - } - case 'resultViewLoaded': - this._panelLoaded = true; - this._panelLoadedCallBacks.forEach((cb) => cb()); - this._panelLoadedCallBacks = []; - break; - case 'changeSort': - await this.changeRawSortState(msg.resultSetName, msg.sortState); - break; - case 'changeInterpretedSort': - await this.changeInterpretedSortState(msg.sortState); - break; - case 'changePage': - if (msg.selectedTable === ALERTS_TABLE_NAME || msg.selectedTable === GRAPH_TABLE_NAME) { - await this.showPageOfInterpretedResults(msg.pageNumber); - } - else { - await this.showPageOfRawResults( - msg.selectedTable, - msg.pageNumber, - // When we are in an unsorted state, we guarantee that - // sortedResultsInfo doesn't have an entry for the current - // result set. Use this to determine whether or not we use - // the sorted bqrs file. - !!this._displayedQuery?.completedQuery.sortedResultsInfo[msg.selectedTable] - ); - } - break; - case 'openFile': - await this.openFile(msg.filePath); - break; - default: - assertNever(msg); - } - } catch (e) { - void showAndLogErrorMessage(getErrorMessage(e), { - fullMessage: getErrorStack(e) - }); - } - } - - postMessage(msg: IntoResultsViewMsg): Thenable { - return this.getPanel().webview.postMessage(msg); - } - - private waitForPanelLoaded(): Promise { - return new Promise((resolve) => { - if (this._panelLoaded) { - resolve(); - } else { - this._panelLoadedCallBacks.push(resolve); - } - }); - } - /** * Show query results in webview panel. * @param fullQuery Evaluation info for the executed query. diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts index 15e8af03f..67859685f 100644 --- a/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts +++ b/extensions/ql-vscode/src/remote-queries/remote-queries-interface.ts @@ -1,11 +1,10 @@ import { - WebviewPanel, ExtensionContext, window as Window, ViewColumn, Uri, workspace, - commands + commands, } from 'vscode'; import * as path from 'path'; @@ -16,7 +15,6 @@ import { RemoteQueryDownloadAllAnalysesResultsMessage } from '../pure/interface-types'; import { Logger } from '../logging'; -import { getHtmlForWebview } from '../interface-utils'; import { assertNever } from '../pure/helpers-pure'; import { AnalysisSummary, @@ -34,18 +32,17 @@ import { SHOW_QUERY_TEXT_MSG } from '../query-history'; import { AnalysesResultsManager } from './analyses-results-manager'; import { AnalysisResults } from './shared/analysis-result'; import { humanizeUnit } from '../pure/time'; +import { AbstractInterfaceManager, InterfacePanelConfig } from '../abstract-interface-manager'; -export class RemoteQueriesInterfaceManager { - private panel: WebviewPanel | undefined; - private panelLoaded = false; +export class RemoteQueriesInterfaceManager extends AbstractInterfaceManager { private currentQueryId: string | undefined; - private panelLoadedCallBacks: (() => void)[] = []; constructor( - private readonly ctx: ExtensionContext, + ctx: ExtensionContext, private readonly logger: Logger, private readonly analysesResultsManager: AnalysesResultsManager ) { + super(ctx); this.panelLoadedCallBacks.push(() => { void logger.log('Variant analysis results view loaded'); }); @@ -103,95 +100,29 @@ export class RemoteQueriesInterfaceManager { }; } - getPanel(): WebviewPanel { - if (this.panel == undefined) { - const { ctx } = this; - const panel = (this.panel = Window.createWebviewPanel( - 'remoteQueriesView', - 'CodeQL Query Results', - { viewColumn: ViewColumn.Active, preserveFocus: true }, - { - enableScripts: true, - enableFindWidget: true, - retainContextWhenHidden: true, - localResourceRoots: [ - Uri.file(this.analysesResultsManager.storagePath), - Uri.file(path.join(this.ctx.extensionPath, 'out')) - ], - } - )); - this.panel.onDidDispose( - () => { - this.panel = undefined; - this.currentQueryId = undefined; - this.panelLoaded = false; - }, - null, - ctx.subscriptions - ); - - panel.webview.html = getHtmlForWebview( - ctx, - panel.webview, - 'remote-queries', - { - allowInlineStyles: true, - } - ); - ctx.subscriptions.push( - panel.webview.onDidReceiveMessage( - async (e) => this.handleMsgFromView(e), - undefined, - ctx.subscriptions - ) - ); - } - return this.panel; - } - - private waitForPanelLoaded(): Promise { - return new Promise((resolve) => { - if (this.panelLoaded) { - resolve(); - } else { - this.panelLoadedCallBacks.push(resolve); + protected getPanelConfig(): InterfacePanelConfig { + return { + viewId: 'remoteQueriesView', + title: 'CodeQL Query Results', + viewColumn: ViewColumn.Active, + preserveFocus: true, + view: 'remote-queries', + additionalOptions: { + localResourceRoots: [ + Uri.file(this.analysesResultsManager.storagePath) + ] } - }); + }; } - private async openFile(filePath: string) { - try { - const textDocument = await workspace.openTextDocument(filePath); - await Window.showTextDocument(textDocument, ViewColumn.One); - } catch (error) { - void showAndLogWarningMessage(`Could not open file: ${filePath}`); - } + protected onPanelDispose(): void { + this.currentQueryId = undefined; } - private async openVirtualFile(text: string) { - try { - const params = new URLSearchParams({ - queryText: encodeURIComponent(SHOW_QUERY_TEXT_MSG + text) - }); - const uri = Uri.parse( - `remote-query:query-text.ql?${params.toString()}`, - true - ); - const doc = await workspace.openTextDocument(uri); - await Window.showTextDocument(doc, { preview: false }); - } catch (error) { - void showAndLogWarningMessage('Could not open query text'); - } - } - - private async handleMsgFromView( - msg: FromRemoteQueriesMessage - ): Promise { + protected async onMessage(msg: FromRemoteQueriesMessage): Promise { switch (msg.t) { case 'remoteQueryLoaded': - this.panelLoaded = true; - this.panelLoadedCallBacks.forEach((cb) => cb()); - this.panelLoadedCallBacks = []; + this.onWebViewLoaded(); break; case 'remoteQueryError': void this.logger.log( @@ -221,6 +152,31 @@ export class RemoteQueriesInterfaceManager { } } + private async openFile(filePath: string) { + try { + const textDocument = await workspace.openTextDocument(filePath); + await Window.showTextDocument(textDocument, ViewColumn.One); + } catch (error) { + void showAndLogWarningMessage(`Could not open file: ${filePath}`); + } + } + + private async openVirtualFile(text: string) { + try { + const params = new URLSearchParams({ + queryText: encodeURIComponent(SHOW_QUERY_TEXT_MSG + text) + }); + const uri = Uri.parse( + `remote-query:query-text.ql?${params.toString()}`, + true + ); + const doc = await workspace.openTextDocument(uri); + await Window.showTextDocument(doc, { preview: false }); + } catch (error) { + void showAndLogWarningMessage('Could not open query text'); + } + } + private async downloadAnalysisResults(msg: RemoteQueryDownloadAnalysisResultsMessage): Promise { const queryId = this.currentQueryId; await this.analysesResultsManager.downloadAnalysisResults( @@ -245,10 +201,6 @@ export class RemoteQueriesInterfaceManager { } } - private postMessage(msg: ToRemoteQueriesMessage): Thenable { - return this.getPanel().webview.postMessage(msg); - } - private getDuration(startTime: number, endTime: number): string { const diffInMs = startTime - endTime; return humanizeUnit(diffInMs); diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-manager.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-manager.ts index 58f772eef..3e46caf36 100644 --- a/extensions/ql-vscode/src/remote-queries/remote-queries-manager.ts +++ b/extensions/ql-vscode/src/remote-queries/remote-queries-manager.ts @@ -75,6 +75,8 @@ export class RemoteQueriesManager extends DisposableObject { this.onRemoteQueryAdded = this.remoteQueryAddedEventEmitter.event; this.onRemoteQueryRemoved = this.remoteQueryRemovedEventEmitter.event; this.onRemoteQueryStatusUpdate = this.remoteQueryStatusUpdateEventEmitter.event; + + this.push(this.interfaceManager); } public async rehydrateRemoteQuery(queryId: string, query: RemoteQuery, status: QueryStatus) {