From 2ab4c1ac140100b1ce1e8076741b5d91c3c36816 Mon Sep 17 00:00:00 2001 From: Andrew Eisenberg Date: Fri, 29 May 2020 13:44:36 -0700 Subject: [PATCH] Extract shared components out of interface.ts This is in preparation for creating a new webview, extract shared functionality to the webview-utils file --- extensions/ql-vscode/src/extension.ts | 16 +- extensions/ql-vscode/src/interface.ts | 404 ++++++++-------------- extensions/ql-vscode/src/webview-utils.ts | 190 ++++++++++ 3 files changed, 352 insertions(+), 258 deletions(-) create mode 100644 extensions/ql-vscode/src/webview-utils.ts diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 75f1f2bf6..04b69be0d 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -21,7 +21,8 @@ import { import * as helpers from './helpers'; import { assertNever } from './helpers-pure'; import { spawnIdeServer } from './ide-server'; -import { InterfaceManager, WebviewReveal } from './interface'; +import { InterfaceManager } from './interface'; +import { WebviewReveal } from './webview-utils'; import { ideServerLogger, logger, queryServerLogger } from './logging'; import { QueryHistoryManager } from './query-history'; import { CompletedQuery } from './query-results'; @@ -30,6 +31,7 @@ import { displayQuickQuery } from './quick-query'; import { compileAndRunQueryAgainstDatabase, tmpDirDisposal, UserCancellationException } from './run-queries'; import { QLTestAdapterFactory } from './test-adapter'; import { TestUIService } from './test-ui'; +import { CompareInterfaceManager } from './compare/compare-interface'; /** * extension.ts @@ -303,14 +305,24 @@ async function activateWithInstalledDistribution(ctx: ExtensionContext, distribu const qhm = new QueryHistoryManager( ctx, queryHistoryConfigurationListener, - async item => showResultsForCompletedQuery(item, WebviewReveal.Forced) + async item => showResultsForCompletedQuery(item, WebviewReveal.Forced), + async (from: CompletedQuery, to: CompletedQuery) => showResultsForComparison(from, to, WebviewReveal.Forced), ); logger.log('Initializing results panel interface.'); const intm = new InterfaceManager(ctx, dbm, cliServer, queryServerLogger); ctx.subscriptions.push(intm); + + logger.log('Initializing compare panel interface.'); + const cmpm = new CompareInterfaceManager(ctx, dbm, cliServer, queryServerLogger); + ctx.subscriptions.push(cmpm); + logger.log('Initializing source archive filesystem provider.'); archiveFilesystemProvider.activate(ctx); + async function showResultsForComparison(from: CompletedQuery, to: CompletedQuery, forceReveal: WebviewReveal): Promise { + await cmpm.showResults(from, to, forceReveal); + } + async function showResultsForCompletedQuery(query: CompletedQuery, forceReveal: WebviewReveal): Promise { await intm.showResults(query, forceReveal, false); } diff --git a/extensions/ql-vscode/src/interface.ts b/extensions/ql-vscode/src/interface.ts index d322c1e3a..784ed79fb 100644 --- a/extensions/ql-vscode/src/interface.ts +++ b/extensions/ql-vscode/src/interface.ts @@ -1,25 +1,56 @@ -import * as crypto from 'crypto'; -import * as path from 'path'; -import * as Sarif from 'sarif'; -import { FivePartLocation, LocationStyle, LocationValue, ResolvableLocationValue, tryGetResolvableLocation, WholeFileLocation } from 'semmle-bqrs'; -import { DisposableObject } from '@github/codeql-vscode-utils'; -import * as vscode from 'vscode'; -import { Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, languages, Location, Range, Uri, window as Window, workspace, env } from 'vscode'; -import * as cli from './cli'; - -import { CodeQLCliServer } from './cli'; -import { DatabaseItem, DatabaseManager } from './databases'; -import { showAndLogErrorMessage } from './helpers'; -import { assertNever } from './helpers-pure'; -import { FromResultsViewMsg, Interpretation, INTERPRETED_RESULTS_PER_RUN_LIMIT, IntoResultsViewMsg, QueryMetadata, ResultsPaths, SortedResultSetInfo, SortedResultsMap, InterpretedResultsSortState, SortDirection, RAW_RESULTS_PAGE_SIZE } from './interface-types'; -import { Logger } from './logging'; -import * as messages from './messages'; -import { CompletedQuery, interpretResults } from './query-results'; -import { QueryInfo, tmpDir } from './run-queries'; -import { parseSarifLocation, parseSarifPlainTextMessage } from './sarif-utils'; -import { adaptSchema, adaptBqrs, RawResultSet, ParsedResultSets } from './adapt'; -import { EXPERIMENTAL_BQRS_SETTING } from './config'; -import { getDefaultResultSetName } from './interface-utils'; +import * as path from "path"; +import * as Sarif from "sarif"; +import { DisposableObject } from "@github/codeql-vscode-utils"; +import * as vscode from "vscode"; +import { + Diagnostic, + DiagnosticRelatedInformation, + DiagnosticSeverity, + languages, + Uri, + window as Window, + env +} from "vscode"; +import * as cli from "./cli"; +import { CodeQLCliServer } from "./cli"; +import { DatabaseItem, DatabaseManager } from "./databases"; +import { showAndLogErrorMessage } from "./helpers"; +import { assertNever } from "./helpers-pure"; +import { + FromResultsViewMsg, + Interpretation, + INTERPRETED_RESULTS_PER_RUN_LIMIT, + IntoResultsViewMsg, + QueryMetadata, + ResultsPaths, + SortedResultSetInfo, + SortedResultsMap, + InterpretedResultsSortState, + SortDirection, + RAW_RESULTS_PAGE_SIZE, +} from "./interface-types"; +import { Logger } from "./logging"; +import * as messages from "./messages"; +import { CompletedQuery, interpretResults } from "./query-results"; +import { QueryInfo, tmpDir } from "./run-queries"; +import { parseSarifLocation, parseSarifPlainTextMessage } from "./sarif-utils"; +import { + adaptSchema, + adaptBqrs, + ParsedResultSets, + RawResultSet, +} from "./adapt"; +import { EXPERIMENTAL_BQRS_SETTING } from "./config"; +import { + WebviewReveal, + fileUriToWebviewUri, + tryResolveLocation, + getHtmlForWebview, + shownLocationDecoration, + shownLocationLineDecoration, + showLocation, +} from "./webview-utils"; +import { getDefaultResultSetName } from "./interface-utils"; /** * interface.ts @@ -29,87 +60,30 @@ import { getDefaultResultSetName } from './interface-utils'; * webview asks us to. */ -/** Gets a nonce string created with 128 bits of entropy. */ -function getNonce(): string { - return crypto.randomBytes(16).toString('base64'); -} - -/** - * Whether to force webview to reveal - */ -export enum WebviewReveal { - Forced, - NotForced, -} - -/** - * Returns HTML to populate the given webview. - * Uses a content security policy that only loads the given script. - */ -function getHtmlForWebview( - webview: vscode.Webview, - scriptUriOnDisk: vscode.Uri, - stylesheetUriOnDisk: vscode.Uri -): void { - // Convert the on-disk URIs into webview URIs. - const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk); - const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk); - // Use a nonce in the content security policy to uniquely identify the above resources. - const nonce = getNonce(); - /* - * Content security policy: - * default-src: allow nothing by default. - * script-src: allow only the given script, using the nonce. - * style-src: allow only the given stylesheet, using the nonce. - * connect-src: only allow fetch calls to webview resource URIs - * (this is used to load BQRS result files). - */ - const html = ` - - - - - - -
-
- - -`; - webview.html = html; -} - -/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */ -export function fileUriToWebviewUri(panel: vscode.WebviewPanel, fileUriOnDisk: Uri): string { - return panel.webview.asWebviewUri(fileUriOnDisk).toString(); -} - -/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */ -export function webviewUriToFileUri(webviewUri: string): Uri { - // Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI. - const path = Uri.parse(webviewUri).path; - // For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform. - return Uri.file(path); -} - function sortMultiplier(sortDirection: SortDirection): number { switch (sortDirection) { - case SortDirection.asc: return 1; - case SortDirection.desc: return -1; + case SortDirection.asc: + return 1; + case SortDirection.desc: + return -1; } } -function sortInterpretedResults(results: Sarif.Result[], sortState: InterpretedResultsSortState | undefined): void { +function sortInterpretedResults( + results: Sarif.Result[], + sortState: InterpretedResultsSortState | undefined +): void { if (sortState !== undefined) { const multiplier = sortMultiplier(sortState.sortDirection); switch (sortState.sortBy) { - case 'alert-message': + case "alert-message": results.sort((a, b) => - a.message.text === undefined ? 0 : - b.message.text === undefined ? 0 : - multiplier * (a.message.text?.localeCompare(b.message.text, env.language))); + a.message.text === undefined + ? 0 + : b.message.text === undefined + ? 0 + : multiplier * a.message.text?.localeCompare(b.message.text, env.language) + ); break; default: assertNever(sortState.sortBy); @@ -145,7 +119,7 @@ export class InterfaceManager extends DisposableObject { this.handleSelectionChange.bind(this) ) ); - logger.log('Registering path-step navigation commands.'); + logger.log("Registering path-step navigation commands."); this.push( vscode.commands.registerCommand( 'codeQLQueryResults.nextPathStep', @@ -184,9 +158,7 @@ export class InterfaceManager extends DisposableObject { } )); this._panel.onDidDispose( - () => { - this._panel = undefined; - }, + () => (this._panel = undefined), null, ctx.subscriptions ); @@ -196,13 +168,13 @@ export class InterfaceManager extends DisposableObject { const stylesheetPathOnDisk = vscode.Uri.file( ctx.asAbsolutePath('out/resultsView.css') ); - getHtmlForWebview( + panel.webview.html = getHtmlForWebview( panel.webview, scriptPathOnDisk, stylesheetPathOnDisk ); panel.webview.onDidReceiveMessage( - async e => this.handleMsgFromView(e), + async (e) => this.handleMsgFromView(e), undefined, ctx.subscriptions ); @@ -222,16 +194,10 @@ export class InterfaceManager extends DisposableObject { // Notify the webview that it should expect new results. await this.postMessage({ t: 'resultsUpdating' }); await update(this._displayedQuery); - await this.showResults( - this._displayedQuery, - WebviewReveal.NotForced, - true - ); + await this.showResults(this._displayedQuery, WebviewReveal.NotForced, true); } - private async handleMsgFromView( - msg: FromResultsViewMsg - ): Promise { + private async handleMsgFromView(msg: FromResultsViewMsg): Promise { switch (msg.t) { case 'viewSourceFile': { const databaseItem = this.databaseManager.findDatabaseItem( @@ -247,9 +213,7 @@ export class InterfaceManager extends DisposableObject { 'Original file of this result is not in the database\'s source archive.' ); } else { - this.logger.log( - `Unable to handleMsgFromView: ${e.message}` - ); + this.logger.log(`Unable to handleMsgFromView: ${e.message}`); } } else { this.logger.log(`Unable to handleMsgFromView: ${e}`); @@ -278,7 +242,7 @@ export class InterfaceManager extends DisposableObject { } case 'resultViewLoaded': this._panelLoaded = true; - this._panelLoadedCallBacks.forEach(cb => cb()); + this._panelLoadedCallBacks.forEach((cb) => cb()); this._panelLoadedCallBacks = []; break; case 'changeSort': @@ -308,7 +272,7 @@ export class InterfaceManager extends DisposableObject { } private waitForPanelLoaded(): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { if (this._panelLoaded) { resolve(); } else { @@ -318,14 +282,14 @@ export class InterfaceManager extends DisposableObject { } /** - * Show query results in webview panel. - * @param results Evaluation info for the executed query. - * @param shouldKeepOldResultsWhileRendering Should keep old results while rendering. - * @param forceReveal Force the webview panel to be visible and - * Appropriate when the user has just performed an explicit - * UI interaction requesting results, e.g. clicking on a query - * history entry. - */ + * Show query results in webview panel. + * @param results Evaluation info for the executed query. + * @param shouldKeepOldResultsWhileRendering Should keep old results while rendering. + * @param forceReveal Force the webview panel to be visible and + * Appropriate when the user has just performed an explicit + * UI interaction requesting results, e.g. clicking on a query + * history entry. + */ public async showResults( results: CompletedQuery, forceReveal: WebviewReveal, @@ -343,9 +307,7 @@ export class InterfaceManager extends DisposableObject { const sortedResultsMap: SortedResultsMap = {}; results.sortedResultsInfo.forEach( (v, k) => - (sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris( - v - )) + (sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(v)) ); this._displayedQuery = results; @@ -363,12 +325,14 @@ export class InterfaceManager extends DisposableObject { const showButton = 'View Results'; const queryName = results.queryName; const resultPromise = vscode.window.showInformationMessage( - `Finished running query ${queryName.length > 0 ? ` "${queryName}"` : ''}.`, + `Finished running query ${ + queryName.length > 0 ? ` "${queryName}"` : "" + }.`, showButton ); // Address this click asynchronously so we still update the // query history immediately. - resultPromise.then(result => { + resultPromise.then((result) => { if (result === showButton) { panel.reveal(); } @@ -377,33 +341,44 @@ export class InterfaceManager extends DisposableObject { const getParsedResultSets = async (): Promise => { if (EXPERIMENTAL_BQRS_SETTING.getValue()) { - const schemas = await this.cliServer.bqrsInfo(results.query.resultsPaths.resultsPath, RAW_RESULTS_PAGE_SIZE); + const schemas = await this.cliServer.bqrsInfo( + results.query.resultsPaths.resultsPath, + RAW_RESULTS_PAGE_SIZE + ); - const resultSetNames = schemas['result-sets'].map(resultSet => resultSet.name); + const resultSetNames = schemas["result-sets"].map( + (resultSet) => resultSet.name + ); // This may not wind up being the page we actually show, if there are interpreted results, // but speculatively send it anyway. const selectedTable = getDefaultResultSetName(resultSetNames); - const schema = schemas['result-sets'].find(resultSet => resultSet.name == selectedTable)!; + const schema = schemas["result-sets"].find( + (resultSet) => resultSet.name == selectedTable + )!; if (schema === undefined) { - return { t: 'WebviewParsed' }; + return { t: "WebviewParsed" }; } - const chunk = await this.cliServer.bqrsDecode(results.query.resultsPaths.resultsPath, schema.name, RAW_RESULTS_PAGE_SIZE, schema.pagination?.offsets[0]); + const chunk = await this.cliServer.bqrsDecode( + results.query.resultsPaths.resultsPath, + schema.name, + RAW_RESULTS_PAGE_SIZE, + schema.pagination?.offsets[0] + ); const adaptedSchema = adaptSchema(schema); const resultSet = adaptBqrs(adaptedSchema, chunk); return { - t: 'ExtensionParsed', + t: "ExtensionParsed", pageNumber: 0, numPages: numPagesOfResultSet(resultSet), resultSet, selectedTable: undefined, - resultSetNames + resultSetNames, }; - } - else { - return { t: 'WebviewParsed' }; + } else { + return { t: "WebviewParsed" }; } }; @@ -418,46 +393,59 @@ export class InterfaceManager extends DisposableObject { sortedResultsMap, database: results.database, shouldKeepOldResultsWhileRendering, - metadata: results.query.metadata + metadata: results.query.metadata, }); } /** * Show a page of raw results from the chosen table. */ - public async showPageOfResults(selectedTable: string, pageNumber: number): Promise { + public async showPageOfResults( + selectedTable: string, + pageNumber: number + ): Promise { const results = this._displayedQuery; if (results === undefined) { - throw new Error('trying to view a page of a query that is not loaded'); + throw new Error("trying to view a page of a query that is not loaded"); } const sortedResultsMap: SortedResultsMap = {}; results.sortedResultsInfo.forEach( (v, k) => - (sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris( - v - )) + (sortedResultsMap[k] = this.convertPathPropertiesToWebviewUris(v)) ); - const schemas = await this.cliServer.bqrsInfo(results.query.resultsPaths.resultsPath, RAW_RESULTS_PAGE_SIZE); + const schemas = await this.cliServer.bqrsInfo( + results.query.resultsPaths.resultsPath, + RAW_RESULTS_PAGE_SIZE + ); - const resultSetNames = schemas['result-sets'].map(resultSet => resultSet.name); + const resultSetNames = schemas["result-sets"].map( + (resultSet) => resultSet.name + ); - const schema = schemas['result-sets'].find(resultSet => resultSet.name == selectedTable)!; + const schema = schemas["result-sets"].find( + (resultSet) => resultSet.name == selectedTable + )!; if (schema === undefined) throw new Error(`Query result set '${selectedTable}' not found.`); - const chunk = await this.cliServer.bqrsDecode(results.query.resultsPaths.resultsPath, schema.name, RAW_RESULTS_PAGE_SIZE, schema.pagination?.offsets[pageNumber]); + const chunk = await this.cliServer.bqrsDecode( + results.query.resultsPaths.resultsPath, + schema.name, + RAW_RESULTS_PAGE_SIZE, + schema.pagination?.offsets[pageNumber] + ); const adaptedSchema = adaptSchema(schema); const resultSet = adaptBqrs(adaptedSchema, chunk); const parsedResultSets: ParsedResultSets = { - t: 'ExtensionParsed', + t: "ExtensionParsed", pageNumber, resultSet, numPages: numPagesOfResultSet(resultSet), selectedTable: selectedTable, - resultSetNames + resultSetNames, }; await this.postMessage({ @@ -471,7 +459,7 @@ export class InterfaceManager extends DisposableObject { sortedResultsMap, database: results.database, shouldKeepOldResultsWhileRendering: false, - metadata: results.query.metadata + metadata: results.query.metadata, }); } @@ -496,16 +484,13 @@ export class InterfaceManager extends DisposableObject { // unresponsive. let numTruncatedResults = 0; - sarif.runs.forEach(run => { + sarif.runs.forEach((run) => { if (run.results !== undefined) { sortInterpretedResults(run.results, sortState); if (run.results.length > INTERPRETED_RESULTS_PER_RUN_LIMIT) { numTruncatedResults += run.results.length - INTERPRETED_RESULTS_PER_RUN_LIMIT; - run.results = run.results.slice( - 0, - INTERPRETED_RESULTS_PER_RUN_LIMIT - ); + run.results = run.results.slice(0, INTERPRETED_RESULTS_PER_RUN_LIMIT); } } }); @@ -513,7 +498,7 @@ export class InterfaceManager extends DisposableObject { sarif, sourceLocationPrefix, numTruncatedResults, - sortState + sortState, }; } @@ -535,9 +520,9 @@ export class InterfaceManager extends DisposableObject { sourceArchiveUri === undefined ? undefined : { - sourceArchive: sourceArchiveUri.fsPath, - sourceLocationPrefix - }; + sourceArchive: sourceArchiveUri.fsPath, + sourceLocationPrefix, + }; interpretation = await this.getTruncatedResults( query.metadata, query.resultsPaths, @@ -569,9 +554,9 @@ export class InterfaceManager extends DisposableObject { sourceArchiveUri === undefined ? undefined : { - sourceArchive: sourceArchiveUri.fsPath, - sourceLocationPrefix - }; + sourceArchive: sourceArchiveUri.fsPath, + sourceLocationPrefix, + }; const interpretation = await this.getTruncatedResults( metadata, resultsInfo, @@ -581,10 +566,7 @@ export class InterfaceManager extends DisposableObject { ); try { - await this.showProblemResultsAsDiagnostics( - interpretation, - database - ); + await this.showProblemResultsAsDiagnostics(interpretation, database); } catch (e) { const msg = e instanceof Error ? e.message : e.toString(); this.logger.log( @@ -687,7 +669,7 @@ export class InterfaceManager extends DisposableObject { ): SortedResultSetInfo { return { resultsPath: this.convertPathToWebviewUri(info.resultsPath), - sortState: info.sortState + sortState: info.sortState, }; } @@ -704,93 +686,3 @@ export class InterfaceManager extends DisposableObject { } } } - -const findMatchBackground = new vscode.ThemeColor('editor.findMatchBackground'); -const findRangeHighlightBackground = new vscode.ThemeColor('editor.findRangeHighlightBackground'); - -const shownLocationDecoration = vscode.window.createTextEditorDecorationType({ - backgroundColor: findMatchBackground, -}); - -const shownLocationLineDecoration = vscode.window.createTextEditorDecorationType({ - backgroundColor: findRangeHighlightBackground, - isWholeLine: true -}); - -async function showLocation(loc: ResolvableLocationValue, databaseItem: DatabaseItem): Promise { - const resolvedLocation = tryResolveLocation(loc, databaseItem); - if (resolvedLocation) { - const doc = await workspace.openTextDocument(resolvedLocation.uri); - const editorsWithDoc = Window.visibleTextEditors.filter(e => e.document === doc); - const editor = editorsWithDoc.length > 0 - ? editorsWithDoc[0] - : await Window.showTextDocument(doc, vscode.ViewColumn.One); - const range = resolvedLocation.range; - // When highlighting the range, vscode's occurrence-match and bracket-match highlighting will - // trigger based on where we place the cursor/selection, and will compete for the user's attention. - // For reference: - // - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected. - // - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection. - // - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not. - // - // For single-line ranges, select the whole range, mainly to disable bracket highlighting. - // For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks. - // Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting. - const selectionEnd = (range.start.line === range.end.line) - ? range.end - : range.start; - editor.selection = new vscode.Selection(range.start, selectionEnd); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - editor.setDecorations(shownLocationDecoration, [range]); - editor.setDecorations(shownLocationLineDecoration, [range]); - } -} - -/** - * Resolves the specified CodeQL location to a URI into the source archive. - * @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`. - * @param databaseItem Database in which to resolve the file location. - */ -function resolveFivePartLocation(loc: FivePartLocation, databaseItem: DatabaseItem): Location { - // `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and - // are one-based. Adjust accordingly. - const range = new Range(Math.max(0, loc.lineStart - 1), - Math.max(0, loc.colStart - 1), - Math.max(0, loc.lineEnd - 1), - Math.max(0, loc.colEnd)); - - return new Location(databaseItem.resolveSourceFile(loc.file), range); -} - -/** - * Resolves the specified CodeQL filesystem resource location to a URI into the source archive. - * @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`. - * @param databaseItem Database in which to resolve the filesystem resource location. - */ -function resolveWholeFileLocation(loc: WholeFileLocation, databaseItem: DatabaseItem): Location { - // A location corresponding to the start of the file. - const range = new Range(0, 0, 0, 0); - return new Location(databaseItem.resolveSourceFile(loc.file), range); -} - -/** - * Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location - * can be resolved, returns `undefined`. - * @param loc CodeQL location to resolve - * @param databaseItem Database in which to resolve the file location. - */ -function tryResolveLocation(loc: LocationValue | undefined, - databaseItem: DatabaseItem): Location | undefined { - const resolvableLoc = tryGetResolvableLocation(loc); - if (resolvableLoc === undefined) { - return undefined; - } - switch (resolvableLoc.t) { - case LocationStyle.FivePart: - return resolveFivePartLocation(resolvableLoc, databaseItem); - case LocationStyle.WholeFile: - return resolveWholeFileLocation(resolvableLoc, databaseItem); - default: - return undefined; - } -} diff --git a/extensions/ql-vscode/src/webview-utils.ts b/extensions/ql-vscode/src/webview-utils.ts new file mode 100644 index 000000000..186c2c516 --- /dev/null +++ b/extensions/ql-vscode/src/webview-utils.ts @@ -0,0 +1,190 @@ +import * as crypto from "crypto"; +import { Uri, Location, Range, WebviewPanel, Webview, window as Window, workspace, ViewColumn, Selection, TextEditorRevealType, ThemeColor } from "vscode"; +import { + FivePartLocation, + LocationStyle, + LocationValue, + tryGetResolvableLocation, + WholeFileLocation, + ResolvableLocationValue, +} from "semmle-bqrs"; +import { DatabaseItem } from "./databases"; + +/** Gets a nonce string created with 128 bits of entropy. */ +export function getNonce(): string { + return crypto.randomBytes(16).toString("base64"); +} + +/** + * Whether to force webview to reveal + */ +export enum WebviewReveal { + Forced, + NotForced, +} + +/** Converts a filesystem URI into a webview URI string that the given panel can use to read the file. */ +export function fileUriToWebviewUri( + panel: WebviewPanel, + fileUriOnDisk: Uri +): string { + return panel.webview.asWebviewUri(fileUriOnDisk).toString(); +} + +/** Converts a URI string received from a webview into a local filesystem URI for the same resource. */ +export function webviewUriToFileUri(webviewUri: string): Uri { + // Webview URIs used the vscode-resource scheme. The filesystem path of the resource can be obtained from the path component of the webview URI. + const path = Uri.parse(webviewUri).path; + // For this path to be interpreted on the filesystem, we need to parse it as a filesystem URI for the current platform. + return Uri.file(path); +} + +/** + * Resolves the specified CodeQL location to a URI into the source archive. + * @param loc CodeQL location to resolve. Must have a non-empty value for `loc.file`. + * @param databaseItem Database in which to resolve the file location. + */ +export function resolveFivePartLocation( + loc: FivePartLocation, + databaseItem: DatabaseItem +): Location { + // `Range` is a half-open interval, and is zero-based. CodeQL locations are closed intervals, and + // are one-based. Adjust accordingly. + const range = new Range( + Math.max(0, loc.lineStart - 1), + Math.max(0, loc.colStart - 1), + Math.max(0, loc.lineEnd - 1), + Math.max(0, loc.colEnd) + ); + + return new Location(databaseItem.resolveSourceFile(loc.file), range); +} + +/** + * Resolves the specified CodeQL filesystem resource location to a URI into the source archive. + * @param loc CodeQL location to resolve, corresponding to an entire filesystem resource. Must have a non-empty value for `loc.file`. + * @param databaseItem Database in which to resolve the filesystem resource location. + */ +export function resolveWholeFileLocation( + loc: WholeFileLocation, + databaseItem: DatabaseItem +): Location { + // A location corresponding to the start of the file. + const range = new Range(0, 0, 0, 0); + return new Location(databaseItem.resolveSourceFile(loc.file), range); +} + +/** + * Try to resolve the specified CodeQL location to a URI into the source archive. If no exact location + * can be resolved, returns `undefined`. + * @param loc CodeQL location to resolve + * @param databaseItem Database in which to resolve the file location. + */ +export function tryResolveLocation( + loc: LocationValue | undefined, + databaseItem: DatabaseItem +): Location | undefined { + const resolvableLoc = tryGetResolvableLocation(loc); + if (resolvableLoc === undefined) { + return undefined; + } + switch (resolvableLoc.t) { + case LocationStyle.FivePart: + return resolveFivePartLocation(resolvableLoc, databaseItem); + case LocationStyle.WholeFile: + return resolveWholeFileLocation(resolvableLoc, databaseItem); + default: + return undefined; + } +} + +/** + * Returns HTML to populate the given webview. + * Uses a content security policy that only loads the given script. + */ +export function getHtmlForWebview( + webview: Webview, + scriptUriOnDisk: Uri, + stylesheetUriOnDisk: Uri +): string { + // Convert the on-disk URIs into webview URIs. + const scriptWebviewUri = webview.asWebviewUri(scriptUriOnDisk); + const stylesheetWebviewUri = webview.asWebviewUri(stylesheetUriOnDisk); + // Use a nonce in the content security policy to uniquely identify the above resources. + const nonce = getNonce(); + /* + * Content security policy: + * default-src: allow nothing by default. + * script-src: allow only the given script, using the nonce. + * style-src: allow only the given stylesheet, using the nonce. + * connect-src: only allow fetch calls to webview resource URIs + * (this is used to load BQRS result files). + */ + return ` + + + + + + +
+
+ + +`; +} + + +const findMatchBackground = new ThemeColor("editor.findMatchBackground"); +const findRangeHighlightBackground = new ThemeColor( + "editor.findRangeHighlightBackground" +); + +export const shownLocationDecoration = Window.createTextEditorDecorationType( + { + backgroundColor: findMatchBackground, + } +); + +export const shownLocationLineDecoration = Window.createTextEditorDecorationType( + { + backgroundColor: findRangeHighlightBackground, + isWholeLine: true, + } +); + +export async function showLocation( + loc: ResolvableLocationValue, + databaseItem: DatabaseItem +): Promise { + const resolvedLocation = tryResolveLocation(loc, databaseItem); + if (resolvedLocation) { + const doc = await workspace.openTextDocument(resolvedLocation.uri); + const editorsWithDoc = Window.visibleTextEditors.filter( + (e) => e.document === doc + ); + const editor = + editorsWithDoc.length > 0 + ? editorsWithDoc[0] + : await Window.showTextDocument(doc, ViewColumn.One); + const range = resolvedLocation.range; + // When highlighting the range, vscode's occurrence-match and bracket-match highlighting will + // trigger based on where we place the cursor/selection, and will compete for the user's attention. + // For reference: + // - Occurences are highlighted when the cursor is next to or inside a word or a whole word is selected. + // - Brackets are highlighted when the cursor is next to a bracket and there is an empty selection. + // - Multi-line selections explicitly highlight line-break characters, but multi-line decorators do not. + // + // For single-line ranges, select the whole range, mainly to disable bracket highlighting. + // For multi-line ranges, place the cursor at the beginning to avoid visual artifacts from selected line-breaks. + // Multi-line ranges are usually large enough to overshadow the noise from bracket highlighting. + const selectionEnd = + range.start.line === range.end.line ? range.end : range.start; + editor.selection = new Selection(range.start, selectionEnd); + editor.revealRange(range, TextEditorRevealType.InCenter); + editor.setDecorations(shownLocationDecoration, [range]); + editor.setDecorations(shownLocationLineDecoration, [range]); + } +}