Add abstract interface manager

This will add a new abstract class that implements the creation of the
panel and webview to reduce duplication across the different interface
managers.
This commit is contained in:
Koen Vlaswinkel
2022-08-26 12:34:28 +02:00
parent 45da1e0f1f
commit b8898b939c
6 changed files with 271 additions and 311 deletions

View File

@@ -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, WebviewView } from './interface-utils';
export type InterfacePanelConfig = {
viewId: string;
title: string;
viewColumn: ViewColumn;
view: WebviewView;
preserveFocus?: boolean;
additionalOptions?: WebviewPanelOptions & WebviewOptions;
}
export abstract class AbstractInterfaceManager<ToMessage extends { t: string }, FromMessage extends { t: string }> 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();
const panel = (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
)
);
panel.webview.html = getHtmlForWebview(
ctx,
panel.webview,
config.view,
{
allowInlineStyles: true,
}
);
this.push(
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<void>;
protected waitForPanelLoaded(): Promise<void> {
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<boolean> {
return this.getPanel().webview.postMessage(msg);
}
}

View File

@@ -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<ToCompareViewMessage, FromCompareViewMessage> {
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<void>
) {
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<void> {
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<void> {
protected async onMessage(msg: FromCompareViewMessage): Promise<void> {
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<boolean> {
return this.getPanel().webview.postMessage(msg);
}
private async findCommonResultSetNames(
from: CompletedLocalQueryInfo,
to: CompletedLocalQueryInfo,

View File

@@ -112,6 +112,8 @@ export function tryResolveLocation(
}
}
export type WebviewView = 'results' | 'compare' | 'remote-queries';
/**
* Returns HTML to populate the given webview.
* Uses a content security policy that only loads the given script.
@@ -119,36 +121,30 @@ export function tryResolveLocation(
export function getHtmlForWebview(
ctx: ExtensionContext,
webview: Webview,
view: 'results' | 'compare' | 'remote-queries',
view: WebviewView,
{
allowInlineStyles,
includeCodicons
}: {
allowInlineStyles?: boolean;
includeCodicons?: boolean;
} = {
allowInlineStyles: false,
includeCodicons: 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
];
if (includeCodicons) {
// 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')
);
stylesheetUrisOnDisk.push(codiconsPathOnDisk);
}
// Convert the on-disk URIs into webview URIs.
const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk);
const stylesheetWebviewUris = stylesheetUrisOnDisk.map(stylesheetUriOnDisk =>

View File

@@ -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<IntoResultsViewMsg, FromResultsViewMsg> {
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<void> {
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<void> {
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<boolean> {
return this.getPanel().webview.postMessage(msg);
}
private waitForPanelLoaded(): Promise<void> {
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.

View File

@@ -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<ToRemoteQueriesMessage, FromRemoteQueriesMessage> {
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,97 +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')),
Uri.file(path.join(this.ctx.extensionPath, 'node_modules/@vscode/codicons/dist')),
],
}
));
this.panel.onDidDispose(
() => {
this.panel = undefined;
this.currentQueryId = undefined;
this.panelLoaded = false;
},
null,
ctx.subscriptions
);
panel.webview.html = getHtmlForWebview(
ctx,
panel.webview,
'remote-queries',
{
includeCodicons: true,
allowInlineStyles: true,
}
);
ctx.subscriptions.push(
panel.webview.onDidReceiveMessage(
async (e) => this.handleMsgFromView(e),
undefined,
ctx.subscriptions
)
);
}
return this.panel;
}
private waitForPanelLoaded(): Promise<void> {
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<void> {
protected async onMessage(msg: FromRemoteQueriesMessage): Promise<void> {
switch (msg.t) {
case 'remoteQueryLoaded':
this.panelLoaded = true;
this.panelLoadedCallBacks.forEach((cb) => cb());
this.panelLoadedCallBacks = [];
this.onWebViewLoaded();
break;
case 'remoteQueryError':
void this.logger.log(
@@ -223,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<void> {
const queryId = this.currentQueryId;
await this.analysesResultsManager.downloadAnalysisResults(
@@ -247,10 +201,6 @@ export class RemoteQueriesInterfaceManager {
}
}
private postMessage(msg: ToRemoteQueriesMessage): Thenable<boolean> {
return this.getPanel().webview.postMessage(msg);
}
private getDuration(startTime: number, endTime: number): string {
const diffInMs = startTime - endTime;
return humanizeUnit(diffInMs);

View File

@@ -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) {