Merge branch 'main' into alexet/prepare-new-qs

This commit is contained in:
Andrew Eisenberg
2022-09-23 08:56:10 -07:00
108 changed files with 16559 additions and 837 deletions

17
.vscode/launch.json vendored
View File

@@ -35,6 +35,9 @@
"runtimeArgs": [
"--inspect=9229"
],
"env": {
"LANG": "en-US"
},
"args": [
"--exit",
"-u",
@@ -43,6 +46,8 @@
"--diff",
"-r",
"ts-node/register",
"-r",
"test/mocha.setup.js",
"test/pure-tests/**/*.ts"
],
"stopOnEntry": false,
@@ -50,6 +55,18 @@
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Launch Unit Tests - React (vscode-codeql)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/extensions/ql-vscode/node_modules/.bin/jest",
"showAsyncStacks": true,
"cwd": "${workspaceFolder}/extensions/ql-vscode",
"stopOnEntry": false,
"sourceMaps": true,
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Launch Integration Tests - No Workspace (vscode-codeql)",
"type": "extensionHost",

View File

@@ -30,12 +30,11 @@
"typescript",
"typescriptreact"
],
"eslint.options": {
// This is necessary so that eslint can properly resolve its plugins
"resolvePluginsRelativeTo": "./extensions/ql-vscode"
},
// This is necessary to ensure that ESLint can find the correct configuration files and plugins.
"eslint.workingDirectories": ["./extensions/ql-vscode"],
"editor.formatOnSave": false,
"typescript.preferences.quoteStyle": "single",
"javascript.preferences.quoteStyle": "single",
"editor.wordWrapColumn": 100
"editor.wordWrapColumn": 100,
"jest.rootPath": "./extensions/ql-vscode"
}

View File

@@ -1,2 +1,4 @@
**/* @github/codeql-vscode-reviewers
/extensions/ql-vscode/src/remote-queries/ @github/code-scanning-secexp-reviewers
/extensions/ql-vscode/src/view/remote-queries/ @github/code-scanning-secexp-reviewers
/extensions/ql-vscode/src/view/variant-analysis/ @github/code-scanning-secexp-reviewers

View File

@@ -160,6 +160,7 @@ From inside of VSCode, open the `launch.json` file and in the _Launch Integratio
* **IMPORTANT** Make sure you are on the `main` branch and your local checkout is fully updated when you add the tag.
* If you accidentally add the tag to the wrong ref, you can just force push it to the right one later.
1. Monitor the status of the release build in the `Release` workflow in the Actions tab.
* DO NOT approve the "publish" stages of the workflow yet.
1. Download the VSIX from the draft GitHub release at the top of [the releases page](https://github.com/github/vscode-codeql/releases) that is created when the release build finishes.
1. Unzip the `.vsix` and inspect its `package.json` to make sure the version is what you expect,
or look at the source if there's any doubt the right code is being shipped.

View File

@@ -10,7 +10,7 @@ module.exports = {
node: true,
es6: true,
},
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:jest-dom/recommended"],
rules: {
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-unused-vars": [

View File

@@ -2,8 +2,11 @@
## [UNRELEASED]
- Removed the ability to manually upgrade databases from the context menu on databases. Databases are non-destructively upgraded automatically so
for most users this was not needed. For advanced users this is still available in the Command Palette. [#1501](https://github.com/github/vscode-codeql/pull/1501)
## 1.7.0 - 20 September 2022
- Remove ability to download databases from LGTM. [#1467](https://github.com/github/vscode-codeql/pull/1467)
- Removed the ability to manually upgrade databases from the context menu on databases. Databases are non-destructively upgraded automatically so for most users this was not needed. For advanced users this is still available in the Command Palette. [#1501](https://github.com/github/vscode-codeql/pull/1501)
- Always restart the query server after a manual database upgrade. This avoids a bug in the query server where an invalid dbscheme was being retained in memory after an upgrade. [#1519](https://github.com/github/vscode-codeql/pull/1519)
## 1.6.12 - 1 September 2022
@@ -19,7 +22,7 @@ No user facing changes.
No user facing changes.
## 1.6.9 - 20 July 2022
## 1.6.9 - 20 July 2022
No user facing changes.

View File

@@ -0,0 +1,214 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/6m/1394pht172qgd7dmw1fwjk100000gn/T/jest_dx",
// Automatically clear mock calls, instances, contexts and results before every test
// clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: 'v8',
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
moduleFileExtensions: [
'js',
'mjs',
'cjs',
'jsx',
'ts',
'tsx',
'json'
],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
'moduleNameMapper': {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/test/__mocks__/fileMock.ts',
'\\.(css|less)$': '<rootDir>/test/__mocks__/styleMock.ts'
},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest',
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['<rootDir>/test/jest.setup.ts'],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'jsdom',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
testMatch: [
'**/__tests__/**/*.[jt]s?(x)'
],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'src/view/tsconfig.spec.json',
},
],
'node_modules': [
'babel-jest',
{
presets: [
'@babel/preset-env'
],
plugins: [
'@babel/plugin-transform-modules-commonjs',
]
}
]
},
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
'transformIgnorePatterns': [
// These use ES modules, so need to be transformed
'node_modules/(?!(?:@vscode/webview-ui-toolkit|@microsoft/.+|exenv-es6)/.*)'
],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "CodeQL for Visual Studio Code",
"author": "GitHub",
"private": true,
"version": "1.6.13",
"version": "1.7.1",
"publisher": "GitHub",
"license": "MIT",
"icon": "media/VS-marketplace-CodeQL-icon.png",
@@ -313,6 +313,10 @@
"command": "codeQL.exportVariantAnalysisResults",
"title": "CodeQL: Export Variant Analysis Results"
},
{
"command": "codeQL.mockVariantAnalysisView",
"title": "CodeQL: Open Variant Analysis Mock View"
},
{
"command": "codeQL.runQueries",
"title": "CodeQL: Run Queries in Selected Files"
@@ -669,7 +673,7 @@
},
{
"command": "codeQLDatabases.chooseDatabaseLgtm",
"when": "view == codeQLDatabases",
"when": "config.codeQL.canary && view == codeQLDatabases",
"group": "navigation"
},
{
@@ -893,6 +897,10 @@
"command": "codeQL.exportVariantAnalysisResults",
"when": "config.codeQL.canary"
},
{
"command": "codeQL.mockVariantAnalysisView",
"when": "config.codeQL.canary && config.codeQL.variantAnalysis.liveResults"
},
{
"command": "codeQL.runQueries",
"when": "false"
@@ -921,6 +929,10 @@
"command": "codeQL.viewCfg",
"when": "resourceScheme == codeql-zip-archive && config.codeQL.canary"
},
{
"command": "codeQL.chooseDatabaseLgtm",
"when": "config.codeQL.canary"
},
{
"command": "codeQLDatabases.setCurrentDatabase",
"when": "false"
@@ -1166,7 +1178,7 @@
},
{
"view": "codeQLDatabases",
"contents": "Add a CodeQL database:\n[From a folder](command:codeQLDatabases.chooseDatabaseFolder)\n[From an archive](command:codeQLDatabases.chooseDatabaseArchive)\n[From a URL (as a zip file)](command:codeQLDatabases.chooseDatabaseInternet)\n[From GitHub](command:codeQLDatabases.chooseDatabaseGithub)\n[From LGTM](command:codeQLDatabases.chooseDatabaseLgtm)"
"contents": "Add a CodeQL database:\n[From a folder](command:codeQLDatabases.chooseDatabaseFolder)\n[From an archive](command:codeQLDatabases.chooseDatabaseArchive)\n[From a URL (as a zip file)](command:codeQLDatabases.chooseDatabaseInternet)\n[From GitHub](command:codeQLDatabases.chooseDatabaseGithub)"
},
{
"view": "codeQLEvalLogViewer",
@@ -1179,7 +1191,9 @@
"watch": "npm-run-all -p watch:*",
"watch:extension": "tsc --watch",
"watch:webpack": "gulp watchView",
"test": "mocha --exit -r ts-node/register test/pure-tests/**/*.ts",
"test": "npm-run-all -p test:*",
"test:unit": "mocha --exit -r ts-node/register -r test/mocha.setup.js test/pure-tests/**/*.ts",
"test:view": "jest",
"preintegration": "rm -rf ./out/vscode-tests && gulp",
"integration": "node ./out/vscode-tests/run-integration-tests.js no-workspace,minimal-workspace",
"cli-integration": "npm run preintegration && node ./out/vscode-tests/run-integration-tests.js cli-integration",
@@ -1231,6 +1245,7 @@
},
"devDependencies": {
"@babel/core": "^7.18.13",
"@babel/plugin-transform-modules-commonjs": "^7.18.6",
"@storybook/addon-actions": "^6.5.10",
"@storybook/addon-essentials": "^6.5.10",
"@storybook/addon-interactions": "^6.5.10",
@@ -1239,6 +1254,9 @@
"@storybook/manager-webpack5": "^6.5.10",
"@storybook/react": "^6.5.10",
"@storybook/testing-library": "^0.0.13",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.4.3",
"@types/chai": "^4.1.7",
"@types/chai-as-promised": "~7.1.2",
"@types/child-process-promise": "^2.2.1",
@@ -1252,6 +1270,7 @@
"@types/gulp": "^4.0.9",
"@types/gulp-replace": "^1.1.0",
"@types/gulp-sourcemaps": "0.0.32",
"@types/jest": "^29.0.2",
"@types/js-yaml": "^3.12.5",
"@types/jszip": "~3.1.6",
"@types/mocha": "^9.0.0",
@@ -1277,14 +1296,16 @@
"@typescript-eslint/eslint-plugin": "^4.26.0",
"@typescript-eslint/parser": "^4.26.0",
"ansi-colors": "^4.1.1",
"applicationinsights": "^1.8.7",
"applicationinsights": "^2.3.5",
"babel-loader": "^8.2.5",
"chai": "^4.2.0",
"chai-as-promised": "~7.1.1",
"css-loader": "~3.1.0",
"del": "^6.0.0",
"eslint": "~6.8.0",
"eslint-plugin-jest-dom": "^4.0.2",
"eslint-plugin-react": "~7.19.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-storybook": "^0.6.4",
"file-loader": "^6.2.0",
"glob": "^7.1.4",
@@ -1293,6 +1314,8 @@
"gulp-sourcemaps": "^3.0.0",
"gulp-typescript": "^5.0.1",
"husky": "~4.3.8",
"jest": "^29.0.3",
"jest-environment-jsdom": "^29.0.3",
"lint-staged": "~10.2.2",
"mini-css-extract-plugin": "^2.6.1",
"mocha": "^10.0.0",
@@ -1300,9 +1323,10 @@
"npm-run-all": "^4.1.5",
"prettier": "~2.0.5",
"proxyquire": "~2.1.3",
"sinon": "~13.0.1",
"sinon": "~14.0.0",
"sinon-chai": "~3.5.0",
"through2": "^4.0.2",
"ts-jest": "^29.0.1",
"ts-loader": "^8.1.0",
"ts-node": "^10.7.0",
"ts-protoc-gen": "^0.9.0",

View File

@@ -13,7 +13,7 @@ import { DisposableObject } from './pure/disposable-object';
import { tmpDir } from './helpers';
import { getHtmlForWebview, WebviewMessage, WebviewView } from './interface-utils';
export type InterfacePanelConfig = {
export type WebviewPanelConfig = {
viewId: string;
title: string;
viewColumn: ViewColumn;
@@ -22,7 +22,7 @@ export type InterfacePanelConfig = {
additionalOptions?: WebviewPanelOptions & WebviewOptions;
}
export abstract class AbstractInterfaceManager<ToMessage extends WebviewMessage, FromMessage extends WebviewMessage> extends DisposableObject {
export abstract class AbstractWebview<ToMessage extends WebviewMessage, FromMessage extends WebviewMessage> extends DisposableObject {
protected panel: WebviewPanel | undefined;
protected panelLoaded = false;
protected panelLoadedCallBacks: (() => void)[] = [];
@@ -90,7 +90,7 @@ export abstract class AbstractInterfaceManager<ToMessage extends WebviewMessage,
return this.panel;
}
protected abstract getPanelConfig(): InterfacePanelConfig;
protected abstract getPanelConfig(): WebviewPanelConfig;
protected abstract onPanelDispose(): void;

View File

@@ -17,14 +17,14 @@ 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';
import { AbstractWebview, WebviewPanelConfig } from '../abstract-webview';
interface ComparePair {
from: CompletedLocalQueryInfo;
to: CompletedLocalQueryInfo;
}
export class CompareInterfaceManager extends AbstractInterfaceManager<ToCompareViewMessage, FromCompareViewMessage> {
export class CompareView extends AbstractWebview<ToCompareViewMessage, FromCompareViewMessage> {
private comparePair: ComparePair | undefined;
constructor(
@@ -95,7 +95,7 @@ export class CompareInterfaceManager extends AbstractInterfaceManager<ToCompareV
}
}
protected getPanelConfig(): InterfacePanelConfig {
protected getPanelConfig(): WebviewPanelConfig {
return {
viewId: 'compareView',
title: 'Compare CodeQL Query Results',
@@ -111,7 +111,7 @@ export class CompareInterfaceManager extends AbstractInterfaceManager<ToCompareV
protected async onMessage(msg: FromCompareViewMessage): Promise<void> {
switch (msg.t) {
case 'compareViewLoaded':
case 'viewLoaded':
this.onWebViewLoaded();
break;

View File

@@ -387,3 +387,13 @@ export function getActionBranch(): string {
export function isIntegrationTestMode() {
return process.env.INTEGRATION_TEST_MODE === 'true';
}
/**
* A flag indicating whether to enable the experimental "live results" feature
* for multi-repo variant analyses.
*/
const LIVE_RESULTS = new Setting('liveResults', REMOTE_QUERIES_SETTING);
export function isVariantAnalysisLiveResultsEnabled(): boolean {
return !!LIVE_RESULTS.getValue<boolean>();
}

View File

@@ -68,7 +68,7 @@ import {
} from './helpers';
import { asError, assertNever, getErrorMessage } from './pure/helpers-pure';
import { spawnIdeServer } from './ide-server';
import { InterfaceManager } from './interface';
import { ResultsView } from './interface';
import { WebviewReveal } from './interface-utils';
import { ideServerLogger, logger, ProgressReporter, queryServerLogger } from './logging';
import { QueryHistoryManager } from './query-history';
@@ -77,7 +77,7 @@ import * as qsClient from './legacy-query-server/queryserver-client';
import { displayQuickQuery } from './quick-query';
import { QLTestAdapterFactory } from './test-adapter';
import { TestUIService } from './test-ui';
import { CompareInterfaceManager } from './compare/compare-interface';
import { CompareView } from './compare/compare-view';
import { gatherQlFiles } from './pure/files';
import { initializeTelemetry } from './telemetry';
import {
@@ -104,6 +104,7 @@ import { LogScannerService } from './log-insights/log-scanner-service';
import { createInitialQueryInfo } from './run-queries-shared';
import { LegacyQueryRunner } from './legacy-query-server/legacyRunner';
import { QueryRunner } from './queryRunner';
import { VariantAnalysisView } from './remote-queries/variant-analysis-view';
/**
* extension.ts
@@ -448,8 +449,8 @@ async function activateWithInstalledDistribution(
const labelProvider = new HistoryItemLabelProvider(queryHistoryConfigurationListener);
void logger.log('Initializing results panel interface.');
const intm = new InterfaceManager(ctx, dbm, cliServer, queryServerLogger, labelProvider);
ctx.subscriptions.push(intm);
const localQueryResultsView = new ResultsView(ctx, dbm, cliServer, queryServerLogger, labelProvider);
ctx.subscriptions.push(localQueryResultsView);
void logger.log('Initializing variant analysis manager.');
const rqm = new RemoteQueriesManager(ctx, cliServer, queryStorageDir, logger);
@@ -459,7 +460,7 @@ async function activateWithInstalledDistribution(
const qhm = new QueryHistoryManager(
qs,
dbm,
intm,
localQueryResultsView,
rqm,
evalLogViewer,
queryStorageDir,
@@ -481,8 +482,8 @@ async function activateWithInstalledDistribution(
void logger.log('Reading query history');
await qhm.readQueryHistory();
void logger.log('Initializing compare panel interface.');
const cmpm = new CompareInterfaceManager(
void logger.log('Initializing compare view.');
const compareView = new CompareView(
ctx,
dbm,
cliServer,
@@ -490,7 +491,7 @@ async function activateWithInstalledDistribution(
labelProvider,
showResults
);
ctx.subscriptions.push(cmpm);
ctx.subscriptions.push(compareView);
void logger.log('Initializing source archive filesystem provider.');
archiveFilesystemProvider.activate(ctx);
@@ -500,7 +501,7 @@ async function activateWithInstalledDistribution(
to: CompletedLocalQueryInfo
): Promise<void> {
try {
await cmpm.showResults(from, to);
await compareView.showResults(from, to);
} catch (e) {
void showAndLogErrorMessage(getErrorMessage(e));
}
@@ -510,7 +511,7 @@ async function activateWithInstalledDistribution(
query: CompletedLocalQueryInfo,
forceReveal: WebviewReveal
): Promise<void> {
await intm.showResults(query, forceReveal, false);
await localQueryResultsView.showResults(query, forceReveal, false);
}
async function compileAndRunQuery(
@@ -901,8 +902,15 @@ async function activateWithInstalledDistribution(
}));
ctx.subscriptions.push(
commandRunner('codeQL.exportVariantAnalysisResults', async () => {
await exportRemoteQueryResults(qhm, rqm, ctx);
commandRunner('codeQL.exportVariantAnalysisResults', async (queryId?: string) => {
await exportRemoteQueryResults(qhm, rqm, ctx, queryId);
})
);
ctx.subscriptions.push(
commandRunner('codeQL.mockVariantAnalysisView', async () => {
const variantAnalysisView = new VariantAnalysisView(ctx);
variantAnalysisView.openView();
})
);

View File

@@ -112,7 +112,7 @@ export function tryResolveLocation(
}
}
export type WebviewView = 'results' | 'compare' | 'remote-queries';
export type WebviewView = 'results' | 'compare' | 'remote-queries' | 'variant-analysis';
export interface WebviewMessage {
t: string;

View File

@@ -43,7 +43,7 @@ import {
} 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 { AbstractWebview, WebviewPanelConfig } from './abstract-webview';
import { PAGE_SIZE } from './config';
import { CompletedLocalQueryInfo } from './query-results';
import { HistoryItemLabelProvider } from './history-item-label-provider';
@@ -119,7 +119,7 @@ function numInterpretedPages(interpretation: Interpretation | undefined): number
return Math.ceil(n / pageSize);
}
export class InterfaceManager extends AbstractInterfaceManager<IntoResultsViewMsg, FromResultsViewMsg> {
export class ResultsView extends AbstractWebview<IntoResultsViewMsg, FromResultsViewMsg> {
private _displayedQuery?: CompletedLocalQueryInfo;
private _interpretation?: Interpretation;
@@ -173,7 +173,7 @@ export class InterfaceManager extends AbstractInterfaceManager<IntoResultsViewMs
await this.postMessage({ t: 'navigatePath', direction });
}
protected getPanelConfig(): InterfacePanelConfig {
protected getPanelConfig(): WebviewPanelConfig {
return {
viewId: 'resultsView',
title: 'CodeQL Query Results',
@@ -190,7 +190,7 @@ export class InterfaceManager extends AbstractInterfaceManager<IntoResultsViewMs
protected async onMessage(msg: FromResultsViewMsg): Promise<void> {
try {
switch (msg.t) {
case 'resultViewLoaded':
case 'viewLoaded':
this.onWebViewLoaded();
break;
case 'viewSourceFile': {

View File

@@ -193,7 +193,14 @@ export async function upgradeDatabaseExplicit(
void qs.logger.log('Running the following database upgrade:');
getUpgradeDescriptions(compileUpgradeResult.compiledUpgrades).map(s => s.description).join('\n');
return await runDatabaseUpgrade(qs, dbItem, compileUpgradeResult.compiledUpgrades, progress, token);
const result = await runDatabaseUpgrade(qs, dbItem, compileUpgradeResult.compiledUpgrades, progress, token);
// TODO Can remove the next lines when https://github.com/github/codeql-team/issues/1241 is fixed
// restart the query server to avoid a bug in the CLI where the upgrade is applied, but the old dbscheme
// is still cached in memory.
await qs.restartQueryServer(progress, token);
return result;
}
catch (e) {
void showAndLogErrorMessage(`Database upgrade failed: ${e}`);

View File

@@ -0,0 +1,26 @@
/*
* Contains an assortment of helper constants and functions for working with dates.
*/
const dateWithoutYearFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
export function formatDate(value: Date): string {
if (value.getFullYear() === new Date().getFullYear()) {
return dateWithoutYearFormatter.format(value);
}
return dateFormatter.format(value);
}

View File

@@ -174,7 +174,7 @@ export type FromResultsViewMsg =
| ToggleDiagnostics
| ChangeRawResultsSortMsg
| ChangeInterpretedResultsSortMsg
| ResultViewLoaded
| ViewLoadedMsg
| ChangePage
| OpenFileMsg;
@@ -216,11 +216,11 @@ interface ToggleDiagnostics {
}
/**
* Message from the results view to signal that loading the results
* is complete.
* Message from a view signal that loading is complete.
*/
interface ResultViewLoaded {
t: 'resultViewLoaded';
interface ViewLoadedMsg {
t: 'viewLoaded';
viewName: string;
}
/**
@@ -279,18 +279,11 @@ interface ChangeInterpretedResultsSortMsg {
* Message from the compare view to the extension.
*/
export type FromCompareViewMessage =
| CompareViewLoadedMessage
| ViewLoadedMsg
| ChangeCompareMessage
| ViewSourceFileMsg
| OpenQueryMessage;
/**
* Message from the compare view to signal the completion of loading results.
*/
interface CompareViewLoadedMessage {
t: 'compareViewLoaded';
}
/**
* Message from the compare view to request opening a query.
*/
@@ -389,7 +382,7 @@ export interface ParsedResultSets {
}
export type FromRemoteQueriesMessage =
| RemoteQueryLoadedMessage
| ViewLoadedMsg
| RemoteQueryErrorMessage
| OpenFileMsg
| OpenVirtualFileMsg
@@ -402,10 +395,6 @@ export type ToRemoteQueriesMessage =
| SetRemoteQueryResultMessage
| SetAnalysesResultsMessage;
export interface RemoteQueryLoadedMessage {
t: 'remoteQueryLoaded';
}
export interface SetRemoteQueryResultMessage {
t: 'setRemoteQueryResult';
queryResult: RemoteQueryResult
@@ -433,6 +422,7 @@ export interface RemoteQueryDownloadAllAnalysesResultsMessage {
export interface RemoteQueryExportResultsMessage {
t: 'remoteQueryExportResults';
queryId: string;
}
export interface CopyRepoListMessage {

View File

@@ -0,0 +1,15 @@
/*
* Contains an assortment of helper constants and functions for working with numbers.
*/
const numberFormatter = new Intl.NumberFormat('en-US');
/**
* Formats a number to be human-readable with decimal places and thousands separators.
*
* @param value The number to format.
* @returns The formatted number. For example, "10,000", "1,000,000", or "1,000,000,000".
*/
export function formatDecimal(value: number): string {
return numberFormatter.format(value);
}

View File

@@ -2,7 +2,8 @@
* Contains an assortment of helper constants and functions for working with time, dates, and durations.
*/
export const ONE_MINUTE_IN_MS = 1000 * 60;
export const ONE_SECOND_IN_MS = 1000;
export const ONE_MINUTE_IN_MS = ONE_SECOND_IN_MS * 60;
export const ONE_HOUR_IN_MS = ONE_MINUTE_IN_MS * 60;
export const TWO_HOURS_IN_MS = ONE_HOUR_IN_MS * 2;
export const THREE_HOURS_IN_MS = ONE_HOUR_IN_MS * 3;
@@ -43,20 +44,23 @@ export function humanizeRelativeTime(relativeTimeMillis?: number) {
/**
* Converts a number of milliseconds into a human-readable string with units, indicating an amount of time.
* Negative numbers have no meaning and are considered to be "Less than a minute".
* Negative numbers have no meaning and are considered to be "Less than a second".
*
* @param millis The number of milliseconds to convert.
* @returns A humanized duration. For example, "2 minutes", "2 hours", "2 days", or "2 months".
* @returns A humanized duration. For example, "2 seconds", "2 minutes", "2 hours", "2 days", or "2 months".
*/
export function humanizeUnit(millis?: number): string {
// assume a blank or empty string is a zero
// assume anything less than 0 is a zero
if (!millis || millis < ONE_MINUTE_IN_MS) {
return 'Less than a minute';
if (!millis || millis < ONE_SECOND_IN_MS) {
return 'Less than a second';
}
let unit: string;
let unitDiff: number;
if (millis < ONE_HOUR_IN_MS) {
if (millis < ONE_MINUTE_IN_MS) {
unit = 'second';
unitDiff = Math.floor(millis / ONE_SECOND_IN_MS);
} else if (millis < ONE_HOUR_IN_MS) {
unit = 'minute';
unitDiff = Math.floor(millis / ONE_MINUTE_IN_MS);
} else if (millis < ONE_DAY_IN_MS) {

View File

@@ -0,0 +1,11 @@
import * as unzipper from 'unzipper';
/**
* Unzips a zip file to a directory.
* @param sourcePath The path to the zip file.
* @param destinationPath The path to the directory to unzip to.
*/
export async function unzipFile(sourcePath: string, destinationPath: string) {
const file = await unzipper.Open.file(sourcePath);
await file.extract({ path: destinationPath });
}

View File

@@ -39,10 +39,10 @@ import * as fs from 'fs-extra';
import { CliVersionConstraint } from './cli';
import { HistoryItemLabelProvider } from './history-item-label-provider';
import { Credentials } from './authentication';
import { cancelRemoteQuery } from './remote-queries/gh-actions-api-client';
import { cancelRemoteQuery } from './remote-queries/gh-api/gh-actions-api-client';
import { RemoteQueriesManager } from './remote-queries/remote-queries-manager';
import { RemoteQueryHistoryItem } from './remote-queries/remote-query-history-item';
import { InterfaceManager } from './interface';
import { ResultsView } from './interface';
import { WebviewReveal } from './interface-utils';
import { EvalLogViewer } from './eval-log-viewer';
import EvalLogTreeBuilder from './eval-log-tree-builder';
@@ -331,7 +331,7 @@ export class QueryHistoryManager extends DisposableObject {
constructor(
private readonly qs: QueryRunner,
private readonly dbm: DatabaseManager,
private readonly localQueriesInterfaceManager: InterfaceManager,
private readonly localQueriesResultsView: ResultsView,
private readonly remoteQueriesManager: RemoteQueriesManager,
private readonly evalLogViewer: EvalLogViewer,
private readonly queryStorageDir: string,
@@ -680,6 +680,10 @@ export class QueryHistoryManager extends DisposableObject {
return this.treeDataProvider.getCurrent();
}
getRemoteQueryById(queryId: string): RemoteQueryHistoryItem | undefined {
return this.treeDataProvider.allHistory.find(i => i.t === 'remote' && i.queryId === queryId) as RemoteQueryHistoryItem;
}
async removeDeletedQueries() {
await Promise.all(this.treeDataProvider.allHistory.map(async (item) => {
if (item.t == 'local' && item.completedQuery && !(await fs.pathExists(item.completedQuery?.query.querySaveDir))) {
@@ -1360,7 +1364,7 @@ the file in the file explorer and dragging it into the workspace.`
private async openQueryResults(item: QueryHistoryInfo) {
if (item.t === 'local') {
await this.localQueriesInterfaceManager.showResults(item as CompletedLocalQueryInfo, WebviewReveal.Forced, false);
await this.localQueriesResultsView.showResults(item as CompletedLocalQueryInfo, WebviewReveal.Forced, false);
}
else if (item.t === 'remote') {
await this.remoteQueriesManager.openRemoteQueryResults(item.queryId);

View File

@@ -5,7 +5,7 @@ import { CancellationToken, ExtensionContext } from 'vscode';
import { Credentials } from '../authentication';
import { Logger } from '../logging';
import { downloadArtifactFromLink } from './gh-actions-api-client';
import { downloadArtifactFromLink } from './gh-api/gh-actions-api-client';
import { AnalysisSummary } from './shared/remote-query-result';
import { AnalysisResults, AnalysisAlert, AnalysisRawResults } from './shared/analysis-result';
import { UserCancellationException } from '../commandRunner';

View File

@@ -10,31 +10,46 @@ import {
} from '../helpers';
import { logger } from '../logging';
import { QueryHistoryManager } from '../query-history';
import { createGist } from './gh-actions-api-client';
import { createGist } from './gh-api/gh-actions-api-client';
import { RemoteQueriesManager } from './remote-queries-manager';
import { generateMarkdown } from './remote-queries-markdown-generation';
import { RemoteQuery } from './remote-query';
import { AnalysisResults, sumAnalysesResults } from './shared/analysis-result';
import { RemoteQueryHistoryItem } from './remote-query-history-item';
/**
* Exports the results of the currently-selected remote query.
* Exports the results of the given or currently-selected remote query.
* The user is prompted to select the export format.
*/
export async function exportRemoteQueryResults(
queryHistoryManager: QueryHistoryManager,
remoteQueriesManager: RemoteQueriesManager,
ctx: ExtensionContext,
queryId?: string,
): Promise<void> {
const queryHistoryItem = queryHistoryManager.getCurrentQueryHistoryItem();
if (!queryHistoryItem || queryHistoryItem.t !== 'remote') {
throw new Error('No variant analysis results currently open. To open results, click an item in the query history view.');
} else if (!queryHistoryItem.completed) {
let queryHistoryItem: RemoteQueryHistoryItem;
if (queryId) {
const query = queryHistoryManager.getRemoteQueryById(queryId);
if (!query) {
void logger.log(`Could not find query with id ${queryId}`);
throw new Error('There was an error when trying to retrieve variant analysis information');
}
queryHistoryItem = query;
} else {
const query = queryHistoryManager.getCurrentQueryHistoryItem();
if (!query || query.t !== 'remote') {
throw new Error('No variant analysis results currently open. To open results, click an item in the query history view.');
}
queryHistoryItem = query;
}
if (!queryHistoryItem.completed) {
throw new Error('Variant analysis results are not yet available.');
}
const queryId = queryHistoryItem.queryId;
void logger.log(`Exporting variant analysis results for query: ${queryId}`);
void logger.log(`Exporting variant analysis results for query: ${queryHistoryItem.queryId}`);
const query = queryHistoryItem.remoteQuery;
const analysesResults = remoteQueriesManager.getAnalysesResults(queryId);
const analysesResults = remoteQueriesManager.getAnalysesResults(queryHistoryItem.queryId);
const gistOption = {
label: '$(ports-open-browser-icon) Create Gist (GitHub)',

View File

@@ -1,14 +1,14 @@
import * as unzipper from 'unzipper';
import * as path from 'path';
import * as fs from 'fs-extra';
import { showAndLogErrorMessage, showAndLogWarningMessage, tmpDir } from '../helpers';
import { Credentials } from '../authentication';
import { logger } from '../logging';
import { RemoteQueryWorkflowResult } from './remote-query-workflow-result';
import { DownloadLink, createDownloadPath } from './download-link';
import { RemoteQuery } from './remote-query';
import { RemoteQueryFailureIndexItem, RemoteQueryResultIndex, RemoteQuerySuccessIndexItem } from './remote-query-result-index';
import { getErrorMessage } from '../pure/helpers-pure';
import { showAndLogErrorMessage, showAndLogWarningMessage, tmpDir } from '../../helpers';
import { Credentials } from '../../authentication';
import { logger } from '../../logging';
import { RemoteQueryWorkflowResult } from '../remote-query-workflow-result';
import { DownloadLink, createDownloadPath } from '../download-link';
import { RemoteQuery } from '../remote-query';
import { RemoteQueryFailureIndexItem, RemoteQueryResultIndex, RemoteQuerySuccessIndexItem } from '../remote-query-result-index';
import { getErrorMessage } from '../../pure/helpers-pure';
import { unzipFile } from '../../pure/zip';
export const RESULT_INDEX_ARTIFACT_NAME = 'result-index';
@@ -110,10 +110,8 @@ export async function downloadArtifactFromLink(
const response = await octokit.request(`GET ${downloadLink.urlPath}/zip`, {});
const zipFilePath = createDownloadPath(storagePath, downloadLink, 'zip');
await saveFile(`${zipFilePath}`, response.data as ArrayBuffer);
// Extract the zipped artifact.
await unzipFile(zipFilePath, extractedPath);
await unzipBuffer(response.data as ArrayBuffer, zipFilePath, extractedPath);
}
return path.join(extractedPath, downloadLink.innerFilePath || '');
}
@@ -300,20 +298,16 @@ async function downloadArtifact(
archive_format: 'zip',
});
const artifactPath = path.join(tmpDir.name, `${artifactId}`);
await saveFile(`${artifactPath}.zip`, response.data as ArrayBuffer);
await unzipFile(`${artifactPath}.zip`, artifactPath);
await unzipBuffer(response.data as ArrayBuffer, `${artifactPath}.zip`, artifactPath);
return artifactPath;
}
async function saveFile(filePath: string, data: ArrayBuffer): Promise<void> {
async function unzipBuffer(data: ArrayBuffer, filePath: string, destinationPath: string): Promise<void> {
void logger.log(`Saving file to ${filePath}`);
await fs.writeFile(filePath, Buffer.from(data));
}
async function unzipFile(sourcePath: string, destinationPath: string) {
void logger.log(`Unzipping file to ${destinationPath}`);
const file = await unzipper.Open.file(sourcePath);
await file.extract({ path: destinationPath });
await unzipFile(filePath, destinationPath);
}
function getWorkflowError(conclusion: string | null): string {

View File

@@ -0,0 +1,85 @@
import { Credentials } from '../../authentication';
import { OctokitResponse } from '@octokit/types/dist-types';
import { VariantAnalysisSubmission } from '../shared/variant-analysis';
import {
VariantAnalysis,
VariantAnalysisRepoTask,
VariantAnalysisSubmissionRequest
} from './variant-analysis';
export async function submitVariantAnalysis(
credentials: Credentials,
submissionDetails: VariantAnalysisSubmission
): Promise<VariantAnalysis> {
const octokit = await credentials.getOctokit();
const { actionRepoRef, query, databases, controllerRepoId } = submissionDetails;
const data: VariantAnalysisSubmissionRequest = {
action_repo_ref: actionRepoRef,
language: query.language,
query_pack: query.pack,
repositories: databases.repositories,
repository_lists: databases.repositoryLists,
repository_owners: databases.repositoryOwners,
};
const response: OctokitResponse<VariantAnalysis> = await octokit.request(
'POST /repositories/:controllerRepoId/code-scanning/codeql/variant-analyses',
{
controllerRepoId,
data
}
);
return response.data;
}
export async function getVariantAnalysis(
credentials: Credentials,
controllerRepoId: number,
variantAnalysisId: number
): Promise<VariantAnalysis> {
const octokit = await credentials.getOctokit();
const response: OctokitResponse<VariantAnalysis> = await octokit.request(
'GET /repositories/:controllerRepoId/code-scanning/codeql/variant-analyses/:variantAnalysisId',
{
controllerRepoId,
variantAnalysisId
}
);
return response.data;
}
export async function getVariantAnalysisRepo(
credentials: Credentials,
controllerRepoId: number,
variantAnalysisId: number,
repoId: number
): Promise<VariantAnalysisRepoTask> {
const octokit = await credentials.getOctokit();
const response: OctokitResponse<VariantAnalysisRepoTask> = await octokit.request(
'GET /repositories/:controllerRepoId/code-scanning/codeql/variant-analyses/:variantAnalysisId/repositories/:repoId',
{
controllerRepoId,
variantAnalysisId,
repoId
}
);
return response.data;
}
export async function getRepositoryIdFromNwo(
credentials: Credentials,
owner: string,
repo: string
): Promise<number> {
const octokit = await credentials.getOctokit();
const response = await octokit.rest.repos.get({ owner, repo });
return response.data.id;
}

View File

@@ -0,0 +1,13 @@
/**
* Defines basic information about a repository.
*
* Different parts of the API may return different subsets of information
* about a repository, but this model represents the very basic information
* that will always be available.
*/
export interface Repository {
id: number,
name: string,
full_name: string,
private: boolean,
}

View File

@@ -0,0 +1,83 @@
import { Repository } from './repository';
export interface VariantAnalysisSubmissionRequest {
action_repo_ref: string,
language: VariantAnalysisQueryLanguage,
query_pack: string,
repositories?: string[],
repository_lists?: string[],
repository_owners?: string[]
}
export type VariantAnalysisQueryLanguage =
| 'csharp'
| 'cpp'
| 'go'
| 'java'
| 'javascript'
| 'python'
| 'ruby';
export interface VariantAnalysis {
id: number,
controller_repo: Repository,
actor_id: number,
query_language: VariantAnalysisQueryLanguage,
query_pack_url: string,
status: VariantAnalysisStatus,
actions_workflow_run_id?: number,
failure_reason?: VariantAnalysisFailureReason,
scanned_repositories?: VariantAnalysisScannedRepository[],
skipped_repositories?: VariantAnalysisSkippedRepositories
}
export type VariantAnalysisStatus =
| 'in_progress'
| 'completed';
export type VariantAnalysisFailureReason =
| 'no_repos_queried'
| 'internal_error';
export type VariantAnalysisRepoStatus =
| 'pending'
| 'in_progress'
| 'succeeded'
| 'failed'
| 'canceled'
| 'timed_out';
export interface VariantAnalysisScannedRepository {
repository: Repository,
analysis_status: VariantAnalysisRepoStatus,
result_count?: number,
artifact_size_in_bytes?: number,
failure_message?: string
}
export interface VariantAnalysisSkippedRepositoryGroup {
repository_count: number,
repositories: Repository[]
}
export interface VariantAnalysisNotFoundRepositoryGroup {
repository_count: number,
repository_nwos: string[]
}
export interface VariantAnalysisRepoTask {
repository: Repository,
analysis_status: VariantAnalysisRepoStatus,
artifact_size_in_bytes?: number,
result_count?: number,
failure_message?: string,
database_commit_sha?: string,
source_location_prefix?: string,
artifact_url?: string
}
export interface VariantAnalysisSkippedRepositories {
access_mismatch_repos: VariantAnalysisSkippedRepositoryGroup,
not_found_repo_nwos: VariantAnalysisNotFoundRepositoryGroup,
no_codeql_db_repos: VariantAnalysisSkippedRepositoryGroup,
over_limit_repos: VariantAnalysisSkippedRepositoryGroup
}

View File

@@ -10,10 +10,10 @@ import { ProgressCallback } from '../commandRunner';
import { createTimestampFile, showAndLogErrorMessage, showAndLogInformationMessage, showInformationMessageWithAction } from '../helpers';
import { Logger } from '../logging';
import { runRemoteQuery } from './run-remote-query';
import { RemoteQueriesInterfaceManager } from './remote-queries-interface';
import { RemoteQueriesView } from './remote-queries-view';
import { RemoteQuery } from './remote-query';
import { RemoteQueriesMonitor } from './remote-queries-monitor';
import { getRemoteQueryIndex, getRepositoriesMetadata, RepositoriesMetadata } from './gh-actions-api-client';
import { getRemoteQueryIndex, getRepositoriesMetadata, RepositoriesMetadata } from './gh-api/gh-actions-api-client';
import { RemoteQueryResultIndex } from './remote-query-result-index';
import { RemoteQueryResult, sumAnalysisSummariesResults } from './remote-query-result';
import { DownloadLink } from './download-link';
@@ -56,7 +56,7 @@ export class RemoteQueriesManager extends DisposableObject {
private readonly remoteQueriesMonitor: RemoteQueriesMonitor;
private readonly analysesResultsManager: AnalysesResultsManager;
private readonly interfaceManager: RemoteQueriesInterfaceManager;
private readonly view: RemoteQueriesView;
constructor(
private readonly ctx: ExtensionContext,
@@ -66,7 +66,7 @@ export class RemoteQueriesManager extends DisposableObject {
) {
super();
this.analysesResultsManager = new AnalysesResultsManager(ctx, cliServer, storagePath, logger);
this.interfaceManager = new RemoteQueriesInterfaceManager(ctx, logger, this.analysesResultsManager);
this.view = new RemoteQueriesView(ctx, logger, this.analysesResultsManager);
this.remoteQueriesMonitor = new RemoteQueriesMonitor(ctx, logger);
this.remoteQueryAddedEventEmitter = this.push(new EventEmitter<NewQueryEvent>());
@@ -76,7 +76,7 @@ export class RemoteQueriesManager extends DisposableObject {
this.onRemoteQueryRemoved = this.remoteQueryRemovedEventEmitter.event;
this.onRemoteQueryStatusUpdate = this.remoteQueryStatusUpdateEventEmitter.event;
this.push(this.interfaceManager);
this.push(this.view);
}
public async rehydrateRemoteQuery(queryId: string, query: RemoteQuery, status: QueryStatus) {
@@ -192,7 +192,7 @@ export class RemoteQueriesManager extends DisposableObject {
await this.analysesResultsManager.loadAnalysesResults(
analysesToDownload,
token,
results => this.interfaceManager.setAnalysisResults(results, queryResult.queryId));
results => this.view.setAnalysisResults(results, queryResult.queryId));
}
public async copyRemoteQueryRepoListToClipboard(queryId: string) {
@@ -248,7 +248,7 @@ export class RemoteQueriesManager extends DisposableObject {
}
public async openResults(query: RemoteQuery, queryResult: RemoteQueryResult) {
await this.interfaceManager.showResults(query, queryResult);
await this.view.showResults(query, queryResult);
}
private async askToOpenResults(query: RemoteQuery, queryResult: RemoteQueryResult): Promise<void> {

View File

@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { Credentials } from '../authentication';
import { Logger } from '../logging';
import { getWorkflowStatus, isArtifactAvailable, RESULT_INDEX_ARTIFACT_NAME } from './gh-actions-api-client';
import { getWorkflowStatus, isArtifactAvailable, RESULT_INDEX_ARTIFACT_NAME } from './gh-api/gh-actions-api-client';
import { RemoteQuery } from './remote-query';
import { RemoteQueryWorkflowResult } from './remote-query-workflow-result';

View File

@@ -32,9 +32,9 @@ 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';
import { AbstractWebview, WebviewPanelConfig } from '../abstract-webview';
export class RemoteQueriesInterfaceManager extends AbstractInterfaceManager<ToRemoteQueriesMessage, FromRemoteQueriesMessage> {
export class RemoteQueriesView extends AbstractWebview<ToRemoteQueriesMessage, FromRemoteQueriesMessage> {
private currentQueryId: string | undefined;
constructor(
@@ -100,7 +100,7 @@ export class RemoteQueriesInterfaceManager extends AbstractInterfaceManager<ToRe
};
}
protected getPanelConfig(): InterfacePanelConfig {
protected getPanelConfig(): WebviewPanelConfig {
return {
viewId: 'remoteQueriesView',
title: 'CodeQL Query Results',
@@ -121,7 +121,7 @@ export class RemoteQueriesInterfaceManager extends AbstractInterfaceManager<ToRe
protected async onMessage(msg: FromRemoteQueriesMessage): Promise<void> {
switch (msg.t) {
case 'remoteQueryLoaded':
case 'viewLoaded':
this.onWebViewLoaded();
break;
case 'remoteQueryError':
@@ -145,7 +145,7 @@ export class RemoteQueriesInterfaceManager extends AbstractInterfaceManager<ToRe
await this.downloadAllAnalysesResults(msg);
break;
case 'remoteQueryExportResults':
await commands.executeCommand('codeQL.exportVariantAnalysisResults');
await commands.executeCommand('codeQL.exportVariantAnalysisResults', msg.queryId);
break;
default:
assertNever(msg);

View File

@@ -0,0 +1,5 @@
export interface Repository {
id: number,
fullName: string,
private: boolean,
}

View File

@@ -0,0 +1,136 @@
import { Repository } from './repository';
export interface VariantAnalysis {
id: number,
controllerRepoId: number,
query: {
name: string,
filePath: string,
language: VariantAnalysisQueryLanguage
},
databases: {
repositories?: string[],
repositoryLists?: string[],
repositoryOwners?: string[],
},
status: VariantAnalysisStatus,
actionsWorkflowRunId?: number,
failureReason?: VariantAnalysisFailureReason,
scannedRepos?: VariantAnalysisScannedRepository[],
skippedRepos?: VariantAnalysisSkippedRepositories
}
export enum VariantAnalysisQueryLanguage {
CSharp = 'csharp',
Cpp = 'cpp',
Go = 'go',
Java = 'java',
Javascript = 'javascript',
Python = 'python',
Ruby = 'ruby'
}
export enum VariantAnalysisStatus {
InProgress = 'inProgress',
Succeeded = 'succeeded',
Failed = 'failed',
Canceled = 'canceled',
}
export enum VariantAnalysisFailureReason {
NoReposQueried = 'noReposQueried',
InternalError = 'internalError',
}
export enum VariantAnalysisRepoStatus {
Pending = 'pending',
InProgress = 'inProgress',
Succeeded = 'succeeded',
Failed = 'failed',
Canceled = 'canceled',
TimedOut = 'timedOut',
}
export interface VariantAnalysisScannedRepository {
repository: Repository,
analysisStatus: VariantAnalysisRepoStatus,
resultCount?: number,
artifactSizeInBytes?: number,
failureMessage?: string
}
export interface VariantAnalysisSkippedRepositories {
accessMismatchRepos?: VariantAnalysisSkippedRepositoryGroup,
notFoundRepos?: VariantAnalysisSkippedRepositoryGroup,
noCodeqlDbRepos?: VariantAnalysisSkippedRepositoryGroup,
overLimitRepos?: VariantAnalysisSkippedRepositoryGroup
}
export interface VariantAnalysisSkippedRepositoryGroup {
repositoryCount: number,
repositories: Array<{
id?: number,
fullName: string
}>
}
/**
* Captures information needed to submit a variant
* analysis for processing.
*/
export interface VariantAnalysisSubmission {
startTime: number,
controllerRepoId: number,
actionRepoRef: string,
query: {
name: string,
filePath: string,
language: VariantAnalysisQueryLanguage,
// Base64 encoded query pack.
pack: string,
},
databases: {
repositories?: string[],
repositoryLists?: string[],
repositoryOwners?: string[],
}
}
/**
* @param repo
* @returns whether the repo scan is in a completed state, i.e. it cannot normally change state anymore
*/
export function hasRepoScanCompleted(repo: VariantAnalysisScannedRepository): boolean {
return [
// All states that indicates the repository has been scanned and cannot
// change status anymore.
VariantAnalysisRepoStatus.Succeeded, VariantAnalysisRepoStatus.Failed,
VariantAnalysisRepoStatus.Canceled, VariantAnalysisRepoStatus.TimedOut,
].includes(repo.analysisStatus);
}
/**
* @param repos
* @returns the total number of results. Will be `undefined` when there are no repos with results.
*/
export function getTotalResultCount(repos: VariantAnalysisScannedRepository[] | undefined): number | undefined {
const reposWithResultCounts = repos?.filter(repo => repo.resultCount !== undefined);
if (reposWithResultCounts === undefined || reposWithResultCounts.length === 0) {
return undefined;
}
return reposWithResultCounts.reduce((acc, repo) => acc + (repo.resultCount ?? 0), 0);
}
/**
* @param skippedRepos
* @returns the total number of skipped repositories.
*/
export function getSkippedRepoCount(skippedRepos: VariantAnalysisSkippedRepositories | undefined): number {
if (!skippedRepos) {
return 0;
}
return Object.values(skippedRepos).reduce((acc, group) => acc + group.repositoryCount, 0);
}

View File

@@ -0,0 +1,28 @@
import { ViewColumn } from 'vscode';
import { AbstractWebview, WebviewPanelConfig } from '../abstract-webview';
import { WebviewMessage } from '../interface-utils';
import { logger } from '../logging';
export class VariantAnalysisView extends AbstractWebview<WebviewMessage, WebviewMessage> {
public openView() {
this.getPanel().reveal(undefined, true);
}
protected getPanelConfig(): WebviewPanelConfig {
return {
viewId: 'variantAnalysisView',
title: 'CodeQL Query Results',
viewColumn: ViewColumn.Active,
preserveFocus: true,
view: 'variant-analysis'
};
}
protected onPanelDispose(): void {
// Nothing to dispose currently.
}
protected async onMessage(msg: WebviewMessage): Promise<void> {
void logger.log('Received message on variant analysis view: ' + msg.t);
}
}

View File

@@ -4,6 +4,7 @@ module.exports = {
},
extends: [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:storybook/recommended",
],
settings: {

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { ThemeProvider } from '@primer/react';
import CodePaths from '../../view/remote-queries/CodePaths';
import { CodePaths } from '../../view/common';
import type { CodeFlow } from '../../remote-queries/shared/analysis-result';
export default {
@@ -112,8 +112,8 @@ PowerShell.args = {
message: {
tokens: [
{
type: 'text',
t: 'This zip file may have a dangerous path'
t: 'text',
text: 'This zip file may have a dangerous path'
}
]
},

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import FileCodeSnippet from '../../view/remote-queries/FileCodeSnippet';
import { FileCodeSnippet } from '../../view/common';
export default {
title: 'File Code Snippet',

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { CodePaths, Codicon as CodiconComponent } from '../../../view/common';
// To regenerate the icons, use the following command from the `extensions/ql-vscode` directory:
// jq -R '[inputs | [splits(", *")] as $row | $row[0]]' < node_modules/@vscode/codicons/dist/codicon.csv > src/stories/common/icon/vscode-icons.json
import icons from './vscode-icons.json';
export default {
title: 'Icon/Codicon',
component: CodiconComponent,
argTypes: {
name: {
control: 'select',
options: icons,
},
},
} as ComponentMeta<typeof CodePaths>;
const Template: ComponentStory<typeof CodiconComponent> = (args) => (
<CodiconComponent {...args} />
);
export const Codicon = Template.bind({});
Codicon.args = {
name: 'account',
label: 'Account'
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { CodePaths, ErrorIcon as ErrorIconComponent } from '../../../view/common';
export default {
title: 'Icon/Error Icon',
component: ErrorIconComponent,
} as ComponentMeta<typeof CodePaths>;
const Template: ComponentStory<typeof ErrorIconComponent> = (args) => (
<ErrorIconComponent {...args} />
);
export const ErrorIcon = Template.bind({});

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { CodePaths, SuccessIcon as SuccessIconComponent } from '../../../view/common';
export default {
title: 'Icon/Success Icon',
component: SuccessIconComponent,
} as ComponentMeta<typeof CodePaths>;
const Template: ComponentStory<typeof SuccessIconComponent> = (args) => (
<SuccessIconComponent {...args} />
);
export const SuccessIcon = Template.bind({});

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { CodePaths, WarningIcon as WarningIconComponent } from '../../../view/common';
export default {
title: 'Icon/Warning Icon',
component: WarningIconComponent,
} as ComponentMeta<typeof CodePaths>;
const Template: ComponentStory<typeof WarningIconComponent> = (args) => (
<WarningIconComponent {...args} />
);
export const WarningIcon = Template.bind({});

View File

@@ -0,0 +1,413 @@
[
"account",
"activate-breakpoints",
"add",
"archive",
"arrow-both",
"arrow-circle-down",
"arrow-circle-left",
"arrow-circle-right",
"arrow-circle-up",
"arrow-down",
"arrow-left",
"arrow-right",
"arrow-small-down",
"arrow-small-left",
"arrow-small-right",
"arrow-small-up",
"arrow-swap",
"arrow-up",
"azure-devops",
"azure",
"beaker-stop",
"beaker",
"bell-dot",
"bell",
"blank",
"bold",
"book",
"bookmark",
"bracket-dot",
"bracket-error",
"briefcase",
"broadcast",
"browser",
"bug",
"calendar",
"call-incoming",
"call-outgoing",
"case-sensitive",
"check-all",
"check",
"checklist",
"chevron-down",
"chevron-left",
"chevron-right",
"chevron-up",
"chrome-close",
"chrome-maximize",
"chrome-minimize",
"chrome-restore",
"circle-filled",
"circle-large-filled",
"circle-large-outline",
"circle-outline",
"circle-slash",
"circuit-board",
"clear-all",
"clippy",
"close-all",
"close",
"cloud-download",
"cloud-upload",
"cloud",
"code",
"collapse-all",
"color-mode",
"combine",
"comment-discussion",
"comment",
"compass-active",
"compass-dot",
"compass",
"copy",
"credit-card",
"dash",
"dashboard",
"database",
"debug-all",
"debug-alt-small",
"debug-alt",
"debug-breakpoint-conditional-unverified",
"debug-breakpoint-conditional",
"debug-breakpoint-data-unverified",
"debug-breakpoint-data",
"debug-breakpoint-function-unverified",
"debug-breakpoint-function",
"debug-breakpoint-log-unverified",
"debug-breakpoint-log",
"debug-breakpoint-unsupported",
"debug-console",
"debug-continue-small",
"debug-continue",
"debug-coverage",
"debug-disconnect",
"debug-line-by-line",
"debug-pause",
"debug-rerun",
"debug-restart-frame",
"debug-restart",
"debug-reverse-continue",
"debug-stackframe-active",
"debug-stackframe-dot",
"debug-stackframe",
"debug-start",
"debug-step-back",
"debug-step-into",
"debug-step-out",
"debug-step-over",
"debug-stop",
"debug",
"desktop-download",
"device-camera-video",
"device-camera",
"device-mobile",
"diff-added",
"diff-ignored",
"diff-modified",
"diff-removed",
"diff-renamed",
"diff",
"discard",
"edit",
"editor-layout",
"ellipsis",
"empty-window",
"error-small",
"error",
"exclude",
"expand-all",
"export",
"extensions",
"eye-closed",
"eye",
"feedback",
"file-binary",
"file-code",
"file-media",
"file-pdf",
"file-submodule",
"file-symlink-directory",
"file-symlink-file",
"file-zip",
"file",
"files",
"filter-filled",
"filter",
"flame",
"fold-down",
"fold-up",
"fold",
"folder-active",
"folder-library",
"folder-opened",
"folder",
"gear",
"gift",
"gist-secret",
"git-commit",
"git-compare",
"git-merge",
"git-pull-request-closed",
"git-pull-request-create",
"git-pull-request-draft",
"git-pull-request",
"github-action",
"github-alt",
"github-inverted",
"github",
"globe",
"go-to-file",
"grabber",
"graph-left",
"graph-line",
"graph-scatter",
"graph",
"gripper",
"group-by-ref-type",
"heart",
"history",
"home",
"horizontal-rule",
"hubot",
"inbox",
"indent",
"info",
"inspect",
"issue-draft",
"issue-reopened",
"issues",
"italic",
"jersey",
"json",
"kebab-vertical",
"key",
"law",
"layers-active",
"layers-dot",
"layers",
"layout-activitybar-left",
"layout-activitybar-right",
"layout-centered",
"layout-menubar",
"layout-panel-center",
"layout-panel-justify",
"layout-panel-left",
"layout-panel-off",
"layout-panel-right",
"layout-panel",
"layout-sidebar-left-off",
"layout-sidebar-left",
"layout-sidebar-right-off",
"layout-sidebar-right",
"layout-statusbar",
"layout",
"library",
"lightbulb-autofix",
"lightbulb",
"link-external",
"link",
"list-filter",
"list-flat",
"list-ordered",
"list-selection",
"list-tree",
"list-unordered",
"live-share",
"loading",
"location",
"lock-small",
"lock",
"magnet",
"mail-read",
"mail",
"markdown",
"megaphone",
"mention",
"menu",
"merge",
"milestone",
"mirror",
"mortar-board",
"move",
"multiple-windows",
"mute",
"new-file",
"new-folder",
"newline",
"no-newline",
"note",
"notebook-template",
"notebook",
"octoface",
"open-preview",
"organization",
"output",
"package",
"paintcan",
"pass-filled",
"pass",
"person-add",
"person",
"pie-chart",
"pin",
"pinned-dirty",
"pinned",
"play-circle",
"play",
"plug",
"preserve-case",
"preview",
"primitive-square",
"project",
"pulse",
"question",
"quote",
"radio-tower",
"reactions",
"record-keys",
"record-small",
"record",
"redo",
"references",
"refresh",
"regex",
"remote-explorer",
"remote",
"remove",
"replace-all",
"replace",
"reply",
"repo-clone",
"repo-force-push",
"repo-forked",
"repo-pull",
"repo-push",
"repo",
"report",
"request-changes",
"rocket",
"root-folder-opened",
"root-folder",
"rss",
"ruby",
"run-above",
"run-all",
"run-below",
"run-errors",
"save-all",
"save-as",
"save",
"screen-full",
"screen-normal",
"search-stop",
"search",
"server-environment",
"server-process",
"server",
"settings-gear",
"settings",
"shield",
"sign-in",
"sign-out",
"smiley",
"sort-precedence",
"source-control",
"split-horizontal",
"split-vertical",
"squirrel",
"star-empty",
"star-full",
"star-half",
"stop-circle",
"symbol-array",
"symbol-boolean",
"symbol-class",
"symbol-color",
"symbol-constant",
"symbol-enum-member",
"symbol-enum",
"symbol-event",
"symbol-field",
"symbol-file",
"symbol-interface",
"symbol-key",
"symbol-keyword",
"symbol-method",
"symbol-misc",
"symbol-namespace",
"symbol-numeric",
"symbol-operator",
"symbol-parameter",
"symbol-property",
"symbol-ruler",
"symbol-snippet",
"symbol-string",
"symbol-structure",
"symbol-variable",
"sync-ignored",
"sync",
"table",
"tag",
"target",
"tasklist",
"telescope",
"terminal-bash",
"terminal-cmd",
"terminal-debian",
"terminal-linux",
"terminal-powershell",
"terminal-tmux",
"terminal-ubuntu",
"terminal",
"text-size",
"three-bars",
"thumbsdown",
"thumbsup",
"tools",
"trash",
"triangle-down",
"triangle-left",
"triangle-right",
"triangle-up",
"twitter",
"type-hierarchy-sub",
"type-hierarchy-super",
"type-hierarchy",
"unfold",
"ungroup-by-ref-type",
"unlock",
"unmute",
"unverified",
"variable-group",
"verified-filled",
"verified",
"versions",
"vm-active",
"vm-connect",
"vm-outline",
"vm-running",
"vm",
"wand",
"warning",
"watch",
"whitespace",
"whole-word",
"window",
"word-wrap",
"workspace-trusted",
"workspace-unknown",
"workspace-untrusted",
"zoom-in",
"zoom-out"
]

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { QueryDetails as QueryDetailsComponent } from '../../view/variant-analysis/QueryDetails';
export default {
title: 'Variant Analysis/Query Details',
component: QueryDetailsComponent,
decorators: [
(Story) => (
<VariantAnalysisContainer>
<Story />
</VariantAnalysisContainer>
)
],
argTypes: {
onOpenQueryFileClick: {
action: 'open-query-file-clicked',
table: {
disable: true,
},
},
onViewQueryTextClick: {
action: 'view-query-text-clicked',
table: {
disable: true,
},
},
}
} as ComponentMeta<typeof QueryDetailsComponent>;
const Template: ComponentStory<typeof QueryDetailsComponent> = (args) => (
<QueryDetailsComponent {...args} />
);
export const QueryDetails = Template.bind({});
QueryDetails.args = {
queryName: 'Query name',
queryFileName: 'example.ql',
};

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { VariantAnalysis as VariantAnalysisComponent } from '../../view/variant-analysis/VariantAnalysis';
export default {
title: 'Variant Analysis/Variant Analysis',
component: VariantAnalysisComponent,
} as ComponentMeta<typeof VariantAnalysisComponent>;
const Template: ComponentStory<typeof VariantAnalysisComponent> = () => (
<VariantAnalysisComponent />
);
export const VariantAnalysis = Template.bind({});

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import { VariantAnalysisActions } from '../../view/variant-analysis/VariantAnalysisActions';
export default {
title: 'Variant Analysis/Variant Analysis Actions',
component: VariantAnalysisActions,
decorators: [
(Story) => (
<VariantAnalysisContainer>
<Story />
</VariantAnalysisContainer>
)
],
argTypes: {
onStopQueryClick: {
action: 'stop-query-clicked',
table: {
disable: true,
},
},
onCopyRepositoryListClick: {
action: 'copy-repository-list-clicked',
table: {
disable: true,
},
},
onExportResultsClick: {
action: 'export-results-clicked',
table: {
disable: true,
},
},
}
} as ComponentMeta<typeof VariantAnalysisActions>;
const Template: ComponentStory<typeof VariantAnalysisActions> = (args) => (
<VariantAnalysisActions {...args} />
);
export const InProgress = Template.bind({});
InProgress.args = {
variantAnalysisStatus: VariantAnalysisStatus.InProgress,
};
export const Succeeded = Template.bind({});
Succeeded.args = {
...InProgress.args,
variantAnalysisStatus: VariantAnalysisStatus.Succeeded,
};
export const Failed = Template.bind({});
Failed.args = {
...InProgress.args,
variantAnalysisStatus: VariantAnalysisStatus.Failed,
};

View File

@@ -0,0 +1,140 @@
import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisHeader } from '../../view/variant-analysis/VariantAnalysisHeader';
import {
VariantAnalysis,
VariantAnalysisQueryLanguage,
VariantAnalysisRepoStatus,
VariantAnalysisScannedRepository,
VariantAnalysisStatus
} from '../../remote-queries/shared/variant-analysis';
export default {
title: 'Variant Analysis/Variant Analysis Header',
component: VariantAnalysisHeader,
decorators: [
(Story) => (
<VariantAnalysisContainer>
<Story />
</VariantAnalysisContainer>
)
],
argTypes: {
onOpenQueryFileClick: {
action: 'open-query-file-clicked',
table: {
disable: true,
},
},
onViewQueryTextClick: {
action: 'view-query-text-clicked',
table: {
disable: true,
},
},
onStopQueryClick: {
action: 'stop-query-clicked',
table: {
disable: true,
},
},
onCopyRepositoryListClick: {
action: 'copy-repository-list-clicked',
table: {
disable: true,
},
},
onExportResultsClick: {
action: 'export-results-clicked',
table: {
disable: true,
},
},
onViewLogsClick: {
action: 'view-logs-clicked',
table: {
disable: true,
}
},
}
} as ComponentMeta<typeof VariantAnalysisHeader>;
const Template: ComponentStory<typeof VariantAnalysisHeader> = (args) => (
<VariantAnalysisHeader {...args} />
);
const buildVariantAnalysis = (data: Partial<VariantAnalysis>) => ({
id: 1,
controllerRepoId: 1,
query: {
name: 'Query name',
filePath: 'example.ql',
language: VariantAnalysisQueryLanguage.Javascript,
},
databases: {},
status: VariantAnalysisStatus.InProgress,
...data,
});
const buildScannedRepo = (id: number, data?: Partial<VariantAnalysisScannedRepository>): VariantAnalysisScannedRepository => ({
repository: {
id: id,
fullName: `octodemo/hello-world-${id}`,
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
...data,
});
export const InProgress = Template.bind({});
InProgress.args = {
variantAnalysis: buildVariantAnalysis({
scannedRepos: [
buildScannedRepo(1, {
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
resultCount: 99_999,
}),
buildScannedRepo(2, {
analysisStatus: VariantAnalysisRepoStatus.Failed,
}),
buildScannedRepo(3, {
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
resultCount: 0,
}),
buildScannedRepo(4),
buildScannedRepo(5),
buildScannedRepo(6),
buildScannedRepo(7),
buildScannedRepo(8),
buildScannedRepo(9),
buildScannedRepo(10),
]
}),
};
export const Succeeded = Template.bind({});
Succeeded.args = {
...InProgress.args,
variantAnalysis: buildVariantAnalysis({
status: VariantAnalysisStatus.Succeeded,
scannedRepos: Array.from({ length: 1000 }, (_, i) => buildScannedRepo(i + 1, {
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
resultCount: 100,
}))
}),
duration: 720_000,
completedAt: new Date(1661263446000),
};
export const Failed = Template.bind({});
Failed.args = {
...InProgress.args,
variantAnalysis: buildVariantAnalysis({
status: VariantAnalysisStatus.Failed,
}),
duration: 10_000,
completedAt: new Date(1661263446000),
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisStats } from '../../view/variant-analysis/VariantAnalysisStats';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
export default {
title: 'Variant Analysis/Variant Analysis Stats',
component: VariantAnalysisStats,
decorators: [
(Story) => (
<VariantAnalysisContainer>
<Story />
</VariantAnalysisContainer>
)
],
argTypes: {
onViewLogsClick: {
action: 'view-logs-clicked',
table: {
disable: true,
},
},
}
} as ComponentMeta<typeof VariantAnalysisStats>;
const Template: ComponentStory<typeof VariantAnalysisStats> = (args) => (
<VariantAnalysisStats {...args} />
);
export const Starting = Template.bind({});
Starting.args = {
variantAnalysisStatus: VariantAnalysisStatus.InProgress,
totalRepositoryCount: 10,
};
export const Started = Template.bind({});
Started.args = {
...Starting.args,
resultCount: 99_999,
completedRepositoryCount: 2,
};
export const StartedWithWarnings = Template.bind({});
StartedWithWarnings.args = {
...Starting.args,
hasWarnings: true,
};
export const Succeeded = Template.bind({});
Succeeded.args = {
...Started.args,
totalRepositoryCount: 1000,
completedRepositoryCount: 1000,
variantAnalysisStatus: VariantAnalysisStatus.Succeeded,
duration: 720_000,
completedAt: new Date(1661263446000),
};
export const SucceededWithWarnings = Template.bind({});
SucceededWithWarnings.args = {
...Succeeded.args,
totalRepositoryCount: 10,
completedRepositoryCount: 2,
hasWarnings: true,
};
export const Failed = Template.bind({});
Failed.args = {
...Starting.args,
variantAnalysisStatus: VariantAnalysisStatus.Failed,
duration: 10_000,
completedAt: new Date(1661263446000),
};
export const Stopped = Template.bind({});
Stopped.args = {
...SucceededWithWarnings.args,
variantAnalysisStatus: VariantAnalysisStatus.Canceled,
};

View File

@@ -3,7 +3,8 @@ module.exports = {
browser: true
},
extends: [
"plugin:react/recommended"
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
settings: {
react: {

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { ChangeEvent, SetStateAction, useCallback } from 'react';
import { VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react';
import { CodeFlow } from '../../../remote-queries/shared/analysis-result';
const getCodeFlowName = (codeFlow: CodeFlow) => {
const filePath = codeFlow.threadFlows[codeFlow.threadFlows.length - 1].fileLink.filePath;
return filePath.substring(filePath.lastIndexOf('/') + 1);
};
type CodeFlowsDropdownProps = {
codeFlows: CodeFlow[];
setSelectedCodeFlow: (value: SetStateAction<CodeFlow>) => void;
}
export const CodeFlowsDropdown = ({
codeFlows,
setSelectedCodeFlow
}: CodeFlowsDropdownProps) => {
const handleChange = useCallback((e: ChangeEvent<HTMLSelectElement>) => {
const selectedOption = e.target;
const selectedIndex = selectedOption.value as unknown as number;
setSelectedCodeFlow(codeFlows[selectedIndex]);
}, [setSelectedCodeFlow, codeFlows]);
return (
<VSCodeDropdown onChange={handleChange}>
{codeFlows.map((codeFlow, index) =>
<VSCodeOption
key={index}
value={index}
>
{getCodeFlowName(codeFlow)}
</VSCodeOption>
)}
</VSCodeDropdown>
);
};

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import { AnalysisMessage, CodeFlow, ResultSeverity } from '../../../remote-queries/shared/analysis-result';
import { ThreadPath } from './ThreadPath';
type CodePathProps = {
codeFlow: CodeFlow;
message: AnalysisMessage;
severity: ResultSeverity;
}
export const CodePath = ({
codeFlow,
message,
severity
}: CodePathProps) => (
<>
{codeFlow.threadFlows.map((threadFlow, index) =>
<ThreadPath
key={index}
threadFlow={threadFlow}
step={index + 1}
message={message}
severity={severity}
isSource={index === 0}
isSink={index === codeFlow.threadFlows.length - 1}
/>
)}
</>
);

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import styled from 'styled-components';
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
import { Overlay } from '@primer/react';
import { AnalysisMessage, CodeFlow, ResultSeverity } from '../../../remote-queries/shared/analysis-result';
import { CodePathsOverlay } from './CodePathsOverlay';
const ShowPathsLink = styled(VSCodeLink)`
cursor: pointer;
`;
type Props = {
codeFlows: CodeFlow[],
ruleDescription: string,
message: AnalysisMessage,
severity: ResultSeverity
};
export const CodePaths = ({
codeFlows,
ruleDescription,
message,
severity
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const linkRef = useRef<HTMLAnchorElement>(null);
const closeOverlay = () => setIsOpen(false);
return (
<>
<ShowPathsLink
onClick={() => setIsOpen(true)}
ref={linkRef}
>
Show paths
</ShowPathsLink>
{isOpen && (
<Overlay
returnFocusRef={linkRef}
onEscape={closeOverlay}
onClickOutside={closeOverlay}
anchorSide="outside-top"
>
<CodePathsOverlay
codeFlows={codeFlows}
ruleDescription={ruleDescription}
message={message}
severity={severity}
onClose={closeOverlay}
/>
</Overlay>
)}
</>
);
};

View File

@@ -0,0 +1,102 @@
import * as React from 'react';
import { useState } from 'react';
import styled from 'styled-components';
import { AnalysisMessage, CodeFlow, ResultSeverity } from '../../../remote-queries/shared/analysis-result';
import { SectionTitle } from '../SectionTitle';
import { VerticalSpace } from '../VerticalSpace';
import { CodeFlowsDropdown } from './CodeFlowsDropdown';
import { CodePath } from './CodePath';
const StyledCloseButton = styled.button`
position: absolute;
top: 1em;
right: 4em;
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border: none;
cursor: pointer;
&:focus-visible {
outline: none
}
`;
const OverlayContainer = styled.div`
height: 100%;
width: 100%;
padding: 2em;
position: fixed;
top: 0;
left: 0;
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
overflow-y: scroll;
`;
const CloseButton = ({ onClick }: { onClick: () => void }) => (
<StyledCloseButton onClick={onClick} tabIndex={-1}>
<span className="codicon codicon-chrome-close" />
</StyledCloseButton>
);
const PathsContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const PathDetailsContainer = styled.div`
padding: 0;
border: 0;
`;
const PathDropdownContainer = styled.div`
flex-grow: 1;
padding: 0 0 0 0.2em;
border: none;
`;
type CodePathsOverlayProps = {
codeFlows: CodeFlow[];
ruleDescription: string;
message: AnalysisMessage;
severity: ResultSeverity;
onClose: () => void;
}
export const CodePathsOverlay = ({
codeFlows,
ruleDescription,
message,
severity,
onClose,
}: CodePathsOverlayProps) => {
const [selectedCodeFlow, setSelectedCodeFlow] = useState(codeFlows[0]);
return (
<OverlayContainer>
<CloseButton onClick={onClose} />
<SectionTitle>{ruleDescription}</SectionTitle>
<VerticalSpace size={2} />
<PathsContainer>
<PathDetailsContainer>
{codeFlows.length} paths available: {selectedCodeFlow.threadFlows.length} steps in
</PathDetailsContainer>
<PathDropdownContainer>
<CodeFlowsDropdown codeFlows={codeFlows} setSelectedCodeFlow={setSelectedCodeFlow} />
</PathDropdownContainer>
</PathsContainer>
<VerticalSpace size={2} />
<CodePath
codeFlow={selectedCodeFlow}
severity={severity}
message={message}
/>
<VerticalSpace size={3} />
</OverlayContainer>
);
};

View File

@@ -0,0 +1,74 @@
import * as React from 'react';
import styled from 'styled-components';
import { VSCodeTag } from '@vscode/webview-ui-toolkit/react';
import { AnalysisMessage, ResultSeverity, ThreadFlow } from '../../../remote-queries/shared/analysis-result';
import { SectionTitle } from '../SectionTitle';
import { FileCodeSnippet } from '../FileCodeSnippet';
const Container = styled.div`
max-width: 55em;
margin-bottom: 1.5em;
`;
const HeaderContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1em;
`;
const TitleContainer = styled.div`
flex-grow: 1;
padding: 0;
border: none;
`;
const TagContainer = styled.div`
padding: 0;
border: none;
`;
type ThreadPathProps = {
threadFlow: ThreadFlow;
step: number;
message: AnalysisMessage;
severity: ResultSeverity;
isSource?: boolean;
isSink?: boolean;
}
export const ThreadPath = ({
threadFlow,
step,
message,
severity,
isSource,
isSink,
}: ThreadPathProps) => (
<Container>
<HeaderContainer>
<TitleContainer>
<SectionTitle>Step {step}</SectionTitle>
</TitleContainer>
{isSource &&
<TagContainer>
<VSCodeTag>Source</VSCodeTag>
</TagContainer>
}
{isSink &&
<TagContainer>
<VSCodeTag>Sink</VSCodeTag>
</TagContainer>
}
</HeaderContainer>
<FileCodeSnippet
fileLink={threadFlow.fileLink}
codeSnippet={threadFlow.codeSnippet}
highlightedRegion={threadFlow.highlightedRegion}
severity={severity}
message={isSink ? message : threadFlow.message}
/>
</Container>
);

View File

@@ -0,0 +1 @@
export * from './CodePaths';

View File

@@ -0,0 +1,43 @@
import * as React from 'react';
import styled from 'styled-components';
import { HighlightedRegion } from '../../../remote-queries/shared/analysis-result';
import { parseHighlightedLine, shouldHighlightLine } from '../../../pure/sarif-utils';
const replaceSpaceAndTabChar = (text: string) => text.replaceAll(' ', '\u00a0').replaceAll('\t', '\u00a0\u00a0\u00a0\u00a0');
const HighlightedSpan = styled.span`
background-color: var(--vscode-editor-findMatchHighlightBackground);
`;
const PlainCode = ({ text }: { text: string }) => {
return <span>{replaceSpaceAndTabChar(text)}</span>;
};
const HighlightedCode = ({ text }: { text: string }) => {
return <HighlightedSpan>{replaceSpaceAndTabChar(text)}</HighlightedSpan>;
};
export const CodeSnippetCode = ({
line,
lineNumber,
highlightedRegion
}: {
line: string,
lineNumber: number,
highlightedRegion?: HighlightedRegion
}) => {
if (!highlightedRegion || !shouldHighlightLine(lineNumber, highlightedRegion)) {
return <PlainCode text={line} />;
}
const partiallyHighlightedLine = parseHighlightedLine(line, lineNumber, highlightedRegion);
return (
<>
<PlainCode text={partiallyHighlightedLine.plainSection1} />
<HighlightedCode text={partiallyHighlightedLine.highlightedSection} />
<PlainCode text={partiallyHighlightedLine.plainSection2} />
</>
);
};

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import styled from 'styled-components';
import { AnalysisMessage, HighlightedRegion, ResultSeverity } from '../../../remote-queries/shared/analysis-result';
import { CodeSnippetCode } from './CodeSnippetCode';
import { CodeSnippetMessage } from './CodeSnippetMessage';
const MessageContainer = styled.div`
padding-top: 0.5em;
padding-bottom: 0.5em;
`;
const LineContainer = styled.div`
display: flex;
`;
const LineNumberContainer = styled.div`
border-style: none;
padding: 0.01em 0.5em 0.2em;
`;
const CodeSnippetLineCodeContainer = styled.div`
flex-grow: 1;
border-style: none;
padding: 0.01em 0.5em 0.2em 1.5em;
word-break: break-word;
`;
type CodeSnippetLineProps = {
line: string,
lineIndex: number,
startingLineIndex: number,
highlightedRegion?: HighlightedRegion,
severity?: ResultSeverity,
message?: AnalysisMessage,
messageChildren?: React.ReactNode,
};
export const CodeSnippetLine = ({
line,
lineIndex,
startingLineIndex,
highlightedRegion,
severity,
message,
messageChildren
}: CodeSnippetLineProps) => {
const shouldShowMessage = message &&
severity &&
highlightedRegion &&
highlightedRegion.endLine == startingLineIndex + lineIndex;
return (
<div>
<LineContainer>
<LineNumberContainer>{startingLineIndex + lineIndex}</LineNumberContainer>
<CodeSnippetLineCodeContainer>
<CodeSnippetCode
line={line}
lineNumber={startingLineIndex + lineIndex}
highlightedRegion={highlightedRegion}
/>
</CodeSnippetLineCodeContainer>
</LineContainer>
{shouldShowMessage &&
<MessageContainer>
<CodeSnippetMessage
message={message}
severity={severity}
>
{messageChildren}
</CodeSnippetMessage>
</MessageContainer>
}
</div>
);
};

View File

@@ -0,0 +1,93 @@
import * as React from 'react';
import styled from 'styled-components';
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
import { AnalysisMessage, ResultSeverity } from '../../../remote-queries/shared/analysis-result';
import { createRemoteFileRef } from '../../../pure/location-link-utils';
import { VerticalSpace } from '../VerticalSpace';
const getSeverityColor = (severity: ResultSeverity) => {
switch (severity) {
case 'Recommendation':
return 'var(--vscode-editorInfo-foreground)';
case 'Warning':
return 'var(--vscode-editorWarning-foreground)';
case 'Error':
return 'var(--vscode-editorError-foreground)';
}
};
const MessageText = styled.div`
font-size: small;
padding-left: 0.5em;
`;
type CodeSnippetMessageContainerProps = {
severity: ResultSeverity;
};
const CodeSnippetMessageContainer = styled.div<CodeSnippetMessageContainerProps>`
border-color: var(--vscode-editor-snippetFinalTabstopHighlightBorder);
border-width: 0.1em;
border-style: solid;
border-left-color: ${props => getSeverityColor(props.severity)};
border-left-width: 0.3em;
padding-top: 1em;
padding-bottom: 1em;
`;
const LocationLink = styled(VSCodeLink)`
font-family: var(--vscode-editor-font-family)
`;
type CodeSnippetMessageProps = {
message: AnalysisMessage,
severity: ResultSeverity,
children: React.ReactNode
};
export const CodeSnippetMessage = ({
message,
severity,
children
}: CodeSnippetMessageProps) => {
return (
<CodeSnippetMessageContainer
severity={severity}
>
<MessageText>
{message.tokens.map((token, index) => {
switch (token.t) {
case 'text':
return <span key={index}>{token.text}</span>;
case 'location':
return (
<LocationLink
key={index}
href={
createRemoteFileRef(
token.location.fileLink,
token.location.highlightedRegion?.startLine,
token.location.highlightedRegion?.endLine
)
}
>
{token.text}
</LocationLink>
);
default:
return <></>;
}
})}
{
children && (
<>
<VerticalSpace size={2} />
{children}
</>
)
}
</MessageText>
</CodeSnippetMessageContainer>
);
};

View File

@@ -0,0 +1,106 @@
import * as React from 'react';
import styled from 'styled-components';
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
import {
AnalysisMessage,
CodeSnippet,
FileLink,
HighlightedRegion,
ResultSeverity
} from '../../../remote-queries/shared/analysis-result';
import { createRemoteFileRef } from '../../../pure/location-link-utils';
import { CodeSnippetMessage } from './CodeSnippetMessage';
import { CodeSnippetLine } from './CodeSnippetLine';
const borderColor = 'var(--vscode-editor-snippetFinalTabstopHighlightBorder)';
const Container = styled.div`
font-family: var(--vscode-editor-font-family);
font-size: small;
`;
const TitleContainer = styled.div`
border: 0.1em solid ${borderColor};
border-top-left-radius: 0.2em;
border-top-right-radius: 0.2em;
padding: 0.5em;
`;
const CodeContainer = styled.div`
border-left: 0.1em solid ${borderColor};
border-right: 0.1em solid ${borderColor};
border-bottom: 0.1em solid ${borderColor};
border-bottom-left-radius: 0.2em;
border-bottom-right-radius: 0.2em;
padding-top: 1em;
padding-bottom: 1em;
`;
type Props = {
fileLink: FileLink,
codeSnippet?: CodeSnippet,
highlightedRegion?: HighlightedRegion,
severity?: ResultSeverity,
message?: AnalysisMessage,
messageChildren?: React.ReactNode,
};
export const FileCodeSnippet = ({
fileLink,
codeSnippet,
highlightedRegion,
severity,
message,
messageChildren,
}: Props) => {
const startingLine = codeSnippet?.startLine || 0;
const endingLine = codeSnippet?.endLine || 0;
const titleFileUri = createRemoteFileRef(
fileLink,
highlightedRegion?.startLine || startingLine,
highlightedRegion?.endLine || endingLine);
if (!codeSnippet) {
return (
<Container>
<TitleContainer>
<VSCodeLink href={titleFileUri}>{fileLink.filePath}</VSCodeLink>
</TitleContainer>
{message && severity &&
<CodeSnippetMessage
message={message}
severity={severity}
>
{messageChildren}
</CodeSnippetMessage>}
</Container>
);
}
const code = codeSnippet.text.split('\n');
return (
<Container>
<TitleContainer>
<VSCodeLink href={titleFileUri}>{fileLink.filePath}</VSCodeLink>
</TitleContainer>
<CodeContainer>
{code.map((line, index) => (
<CodeSnippetLine
key={index}
line={line}
lineIndex={index}
startingLineIndex={startingLine}
highlightedRegion={highlightedRegion}
severity={severity}
message={message}
messageChildren={messageChildren}
/>
))}
</CodeContainer>
</Container>
);
};

View File

@@ -0,0 +1 @@
export * from './FileCodeSnippet';

View File

@@ -1,9 +1,7 @@
import styled from 'styled-components';
const HorizontalSpace = styled.div<{ size: 1 | 2 | 3 }>`
export const HorizontalSpace = styled.div<{ size: 1 | 2 | 3 }>`
flex: 0 0 auto;
display: inline-block;
width: ${props => 0.2 * props.size}em;
`;
export default HorizontalSpace;

View File

@@ -1,6 +1,6 @@
import styled from 'styled-components';
const SectionTitle = styled.h2`
export const SectionTitle = styled.h2`
font-size: medium;
font-weight: 500;
padding: 0 0.5em 0 0;
@@ -8,5 +8,3 @@ const SectionTitle = styled.h2`
display: inline-block;
vertical-align: middle;
`;
export default SectionTitle;

View File

@@ -1,8 +1,6 @@
import styled from 'styled-components';
const VerticalSpace = styled.div<{ size: 1 | 2 | 3 }>`
export const VerticalSpace = styled.div<{ size: 1 | 2 | 3 }>`
flex: 0 0 auto;
height: ${props => 0.5 * props.size}em;
`;
export default VerticalSpace;

View File

@@ -0,0 +1,11 @@
import styled from 'styled-components';
export const ViewTitle = styled.h1`
font-size: 2em;
margin-bottom: 0.5em;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import styled from 'styled-components';
import classNames from 'classnames';
type Props = {
name: string;
label: string;
className?: string;
};
const CodiconIcon = styled.span`
vertical-align: text-bottom;
`;
export const Codicon = ({
name,
label,
className
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} />;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import styled from 'styled-components';
import { Codicon } from './Codicon';
const Icon = styled(Codicon)`
color: var(--vscode-problemsErrorIcon-foreground);
`;
type Props = {
label?: string;
className?: string;
}
export const ErrorIcon = ({
label = 'Error',
className,
}: Props) => <Icon name="error" label={label} className={className} />;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import styled from 'styled-components';
import { Codicon } from './Codicon';
const Icon = styled(Codicon)`
color: var(--vscode-testing-iconPassed);
`;
type Props = {
label?: string;
className?: string;
}
export const SuccessIcon = ({
label = 'Success',
className,
}: Props) => <Icon name="pass" label={label} className={className} />;

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import styled from 'styled-components';
import { Codicon } from './Codicon';
const Icon = styled(Codicon)`
color: var(--vscode-problemsWarningIcon-foreground);
`;
type Props = {
label?: string;
className?: string;
}
export const WarningIcon = ({
label = 'Warning',
className,
}: Props) => <Icon name="warning" label={label} className={className} />;

View File

@@ -0,0 +1,4 @@
export * from './Codicon';
export * from './ErrorIcon';
export * from './SuccessIcon';
export * from './WarningIcon';

View File

@@ -0,0 +1,7 @@
export * from './icon';
export * from './CodePaths';
export * from './FileCodeSnippet';
export * from './HorizontalSpace';
export * from './SectionTitle';
export * from './VerticalSpace';
export * from './ViewTitle';

View File

@@ -1,10 +1,9 @@
import * as React from 'react';
import { WebviewDefinition } from '../webview-interface';
import { WebviewDefinition } from '../webview-definition';
import { Compare } from './Compare';
const definition: WebviewDefinition = {
component: <Compare />,
loadedMessage: 'compareViewLoaded'
component: <Compare />
};
export default definition;

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { AnalysisAlert } from '../../remote-queries/shared/analysis-result';
import CodePaths from './CodePaths';
import FileCodeSnippet from './FileCodeSnippet';
import { CodePaths, FileCodeSnippet } from '../common';
const AnalysisAlertResult = ({ alert }: { alert: AnalysisAlert }) => {
const showPathsLink = alert.codeFlows.length > 0;

View File

@@ -1,178 +0,0 @@
import { XCircleIcon } from '@primer/octicons-react';
import { Overlay } from '@primer/react';
import { VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTag } from '@vscode/webview-ui-toolkit/react';
import * as React from 'react';
import { ChangeEvent, useRef, useState } from 'react';
import styled from 'styled-components';
import { CodeFlow, AnalysisMessage, ResultSeverity } from '../../remote-queries/shared/analysis-result';
import FileCodeSnippet from './FileCodeSnippet';
import SectionTitle from './SectionTitle';
import VerticalSpace from './VerticalSpace';
const StyledCloseButton = styled.button`
position: absolute;
top: 1em;
right: 4em;
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border: none;
&:focus-visible {
outline: none
}
`;
const OverlayContainer = styled.div`
padding: 1em;
height: 100%;
width: 100%;
padding: 2em;
position: fixed;
top: 0;
left: 0;
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
overflow-y: scroll;
`;
const CloseButton = ({ onClick }: { onClick: () => void }) => (
<StyledCloseButton onClick={onClick} tabIndex={-1} >
<XCircleIcon size={24} />
</StyledCloseButton>
);
const CodePath = ({
codeFlow,
message,
severity
}: {
codeFlow: CodeFlow;
message: AnalysisMessage;
severity: ResultSeverity;
}) => {
return <>
{codeFlow.threadFlows.map((threadFlow, index) =>
<div key={`thread-flow-${index}`} style={{ maxWidth: '55em' }}>
{index !== 0 && <VerticalSpace size={3} />}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ flexGrow: 1, padding: 0, border: 'none' }}>
<SectionTitle>Step {index + 1}</SectionTitle>
</div>
{index === 0 &&
<div style={{ padding: 0, border: 'none' }}>
<VSCodeTag>Source</VSCodeTag>
</div>
}
{index === codeFlow.threadFlows.length - 1 &&
<div style={{ padding: 0, border: 'none' }}>
<VSCodeTag>Sink</VSCodeTag>
</div>
}
</div>
<VerticalSpace size={2} />
<FileCodeSnippet
fileLink={threadFlow.fileLink}
codeSnippet={threadFlow.codeSnippet}
highlightedRegion={threadFlow.highlightedRegion}
severity={severity}
message={index === codeFlow.threadFlows.length - 1 ? message : threadFlow.message} />
</div>
)}
</>;
};
const getCodeFlowName = (codeFlow: CodeFlow) => {
const filePath = codeFlow.threadFlows[codeFlow.threadFlows.length - 1].fileLink.filePath;
return filePath.substring(filePath.lastIndexOf('/') + 1);
};
const Menu = ({
codeFlows,
setSelectedCodeFlow
}: {
codeFlows: CodeFlow[],
setSelectedCodeFlow: (value: React.SetStateAction<CodeFlow>) => void
}) => {
return <VSCodeDropdown
onChange={(event: ChangeEvent<HTMLSelectElement>) => {
const selectedOption = event.target;
const selectedIndex = selectedOption.value as unknown as number;
setSelectedCodeFlow(codeFlows[selectedIndex]);
}}
>
{codeFlows.map((codeFlow, index) =>
<VSCodeOption
key={`codeflow-${index}'`}
value={index}
>
{getCodeFlowName(codeFlow)}
</VSCodeOption>
)}
</VSCodeDropdown>;
};
const CodePaths = ({
codeFlows,
ruleDescription,
message,
severity
}: {
codeFlows: CodeFlow[],
ruleDescription: string,
message: AnalysisMessage,
severity: ResultSeverity
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedCodeFlow, setSelectedCodeFlow] = useState(codeFlows[0]);
const anchorRef = useRef<HTMLDivElement>(null);
const linkRef = useRef<HTMLAnchorElement>(null);
const closeOverlay = () => setIsOpen(false);
return (
<div ref={anchorRef}>
<VSCodeLink
onClick={() => setIsOpen(true)}
ref={linkRef}
sx={{ cursor: 'pointer' }}>
Show paths
</VSCodeLink>
{isOpen && (
<Overlay
returnFocusRef={linkRef}
onEscape={closeOverlay}
onClickOutside={closeOverlay}
anchorSide="outside-top">
<OverlayContainer>
<CloseButton onClick={closeOverlay} />
<SectionTitle>{ruleDescription}</SectionTitle>
<VerticalSpace size={2} />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ padding: 0, border: 0 }}>
{codeFlows.length} paths available: {selectedCodeFlow.threadFlows.length} steps in
</div>
<div style={{ flexGrow: 1, padding: 0, paddingLeft: '0.2em', border: 'none' }}>
<Menu codeFlows={codeFlows} setSelectedCodeFlow={setSelectedCodeFlow} />
</div>
</div>
<VerticalSpace size={2} />
<CodePath
codeFlow={selectedCodeFlow}
severity={severity}
message={message} />
<VerticalSpace size={3} />
</OverlayContainer>
</Overlay>
)}
</div>
);
};
export default CodePaths;

View File

@@ -1,261 +0,0 @@
import * as React from 'react';
import styled from 'styled-components';
import { CodeSnippet, FileLink, HighlightedRegion, AnalysisMessage, ResultSeverity } from '../../remote-queries/shared/analysis-result';
import VerticalSpace from './VerticalSpace';
import { createRemoteFileRef } from '../../pure/location-link-utils';
import { parseHighlightedLine, shouldHighlightLine } from '../../pure/sarif-utils';
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
const borderColor = 'var(--vscode-editor-snippetFinalTabstopHighlightBorder)';
const warningColor = '#966C23';
const highlightColor = 'var(--vscode-editor-findMatchHighlightBackground)';
const getSeverityColor = (severity: ResultSeverity) => {
switch (severity) {
case 'Recommendation':
return 'blue';
case 'Warning':
return warningColor;
case 'Error':
return 'red';
}
};
const replaceSpaceAndTabChar = (text: string) => text.replaceAll(' ', '\u00a0').replaceAll('\t', '\u00a0\u00a0\u00a0\u00a0');
const Container = styled.div`
font-family: var(--vscode-editor-font-family);
font-size: small;
`;
const TitleContainer = styled.div`
border: 0.1em solid ${borderColor};
border-top-left-radius: 0.2em;
border-top-right-radius: 0.2em;
padding: 0.5em;
`;
const CodeContainer = styled.div`
border-left: 0.1em solid ${borderColor};
border-right: 0.1em solid ${borderColor};
border-bottom: 0.1em solid ${borderColor};
border-bottom-left-radius: 0.2em;
border-bottom-right-radius: 0.2em;
padding-top: 1em;
padding-bottom: 1em;
`;
const MessageText = styled.div`
font-size: small;
padding-left: 0.5em;
`;
const MessageContainer = styled.div`
padding-top: 0.5em;
padding-bottom: 0.5em;
`;
const PlainCode = ({ text }: { text: string }) => {
return <span>{replaceSpaceAndTabChar(text)}</span>;
};
const HighlightedCode = ({ text }: { text: string }) => {
return <span style={{ backgroundColor: highlightColor }}>{replaceSpaceAndTabChar(text)}</span>;
};
const Message = ({
message,
borderLeftColor,
children
}: {
message: AnalysisMessage,
borderLeftColor: string,
children: React.ReactNode
}) => {
return <div style={{
borderColor: borderColor,
borderWidth: '0.1em',
borderStyle: 'solid',
borderLeftColor: borderLeftColor,
borderLeftWidth: '0.3em',
paddingTop: '1em',
paddingBottom: '1em'
}}>
<MessageText>
{message.tokens.map((token, index) => {
switch (token.t) {
case 'text':
return <span key={`token-${index}`}>{token.text}</span>;
case 'location':
return <VSCodeLink
style={{ fontFamily: 'var(--vscode-editor-font-family)' }}
key={`token-${index}`}
href={createRemoteFileRef(
token.location.fileLink,
token.location.highlightedRegion?.startLine,
token.location.highlightedRegion?.endLine)}>
{token.text}
</VSCodeLink>;
default:
return <></>;
}
})}
{children && <>
<VerticalSpace size={2} />
{children}
</>
}
</MessageText>
</div>;
};
const Code = ({
line,
lineNumber,
highlightedRegion
}: {
line: string,
lineNumber: number,
highlightedRegion?: HighlightedRegion
}) => {
if (!highlightedRegion || !shouldHighlightLine(lineNumber, highlightedRegion)) {
return <PlainCode text={line} />;
}
const partiallyHighlightedLine = parseHighlightedLine(line, lineNumber, highlightedRegion);
return (
<>
<PlainCode text={partiallyHighlightedLine.plainSection1} />
<HighlightedCode text={partiallyHighlightedLine.highlightedSection} />
<PlainCode text={partiallyHighlightedLine.plainSection2} />
</>
);
};
const Line = ({
line,
lineIndex,
startingLineIndex,
highlightedRegion,
severity,
message,
messageChildren
}: {
line: string,
lineIndex: number,
startingLineIndex: number,
highlightedRegion?: HighlightedRegion,
severity?: ResultSeverity,
message?: AnalysisMessage,
messageChildren?: React.ReactNode,
}) => {
const shouldShowMessage = message &&
severity &&
highlightedRegion &&
highlightedRegion.endLine == startingLineIndex + lineIndex;
return <div>
<div style={{ display: 'flex' }} >
<div style={{
borderStyle: 'none',
paddingTop: '0.01em',
paddingLeft: '0.5em',
paddingRight: '0.5em',
paddingBottom: '0.2em'
}}>
{startingLineIndex + lineIndex}
</div>
<div style={{
flexGrow: 1,
borderStyle: 'none',
paddingTop: '0.01em',
paddingLeft: '1.5em',
paddingRight: '0.5em',
paddingBottom: '0.2em',
wordBreak: 'break-word'
}}>
<Code
line={line}
lineNumber={startingLineIndex + lineIndex}
highlightedRegion={highlightedRegion} />
</div>
</div>
{shouldShowMessage &&
<MessageContainer>
<Message
message={message}
borderLeftColor={getSeverityColor(severity)}>
{messageChildren}
</Message>
</MessageContainer>
}
</div>;
};
const FileCodeSnippet = ({
fileLink,
codeSnippet,
highlightedRegion,
severity,
message,
messageChildren,
}: {
fileLink: FileLink,
codeSnippet?: CodeSnippet,
highlightedRegion?: HighlightedRegion,
severity?: ResultSeverity,
message?: AnalysisMessage,
messageChildren?: React.ReactNode,
}) => {
const startingLine = codeSnippet?.startLine || 0;
const endingLine = codeSnippet?.endLine || 0;
const titleFileUri = createRemoteFileRef(
fileLink,
highlightedRegion?.startLine || startingLine,
highlightedRegion?.endLine || endingLine);
if (!codeSnippet) {
return (
<Container>
<TitleContainer>
<VSCodeLink href={titleFileUri}>{fileLink.filePath}</VSCodeLink>
</TitleContainer>
{message && severity &&
<Message
message={message}
borderLeftColor={getSeverityColor(severity)}>
{messageChildren}
</Message>}
</Container>
);
}
const code = codeSnippet.text.split('\n');
return (
<Container>
<TitleContainer>
<VSCodeLink href={titleFileUri}>{fileLink.filePath}</VSCodeLink>
</TitleContainer>
<CodeContainer>
{code.map((line, index) => (
<Line
key={`line-${index}`}
line={line}
lineIndex={index}
startingLineIndex={startingLine}
highlightedRegion={highlightedRegion}
severity={severity}
message={message}
messageChildren={messageChildren}
/>
))}
</CodeContainer>
</Container>
);
};
export default FileCodeSnippet;

View File

@@ -6,10 +6,7 @@ import { AnalysisSummary, RemoteQueryResult } from '../../remote-queries/shared/
import { MAX_RAW_RESULTS } from '../../remote-queries/shared/result-limits';
import { vscode } from '../vscode-api';
import { VSCodeBadge, VSCodeButton } from '@vscode/webview-ui-toolkit/react';
import SectionTitle from './SectionTitle';
import VerticalSpace from './VerticalSpace';
import HorizontalSpace from './HorizontalSpace';
import ViewTitle from './ViewTitle';
import { HorizontalSpace, SectionTitle, VerticalSpace, ViewTitle } from '../common';
import DownloadButton from './DownloadButton';
import { AnalysisResults, getAnalysisResultCount } from '../../remote-queries/shared/analysis-result';
import DownloadSpinner from './DownloadSpinner';
@@ -273,9 +270,10 @@ const AnalysesResultsTitle = ({ totalAnalysesResults, totalResults }: { totalAna
return <SectionTitle>{totalAnalysesResults}/{totalResults} results</SectionTitle>;
};
const exportResults = () => {
const exportResults = (queryResult: RemoteQueryResult) => {
vscode.postMessage({
t: 'remoteQueryExportResults',
queryId: queryResult.queryId,
});
};
@@ -365,7 +363,7 @@ const AnalysesResults = ({
totalResults={totalResults} />
</div>
<div>
<VSCodeButton onClick={exportResults}>Export all</VSCodeButton>
<VSCodeButton onClick={() => exportResults(queryResult)}>Export all</VSCodeButton>
</div>
</div>
<AnalysesResultsDescription

View File

@@ -1,9 +0,0 @@
import styled from 'styled-components';
const ViewTitle = styled.h1`
font-size: large;
margin-bottom: 0.5em;
font-weight: 500;
`;
export default ViewTitle;

View File

@@ -1,10 +1,9 @@
import * as React from 'react';
import { WebviewDefinition } from '../webview-interface';
import { WebviewDefinition } from '../webview-definition';
import { RemoteQueries } from './RemoteQueries';
const definition: WebviewDefinition = {
component: <RemoteQueries />,
loadedMessage: 'remoteQueryLoaded'
component: <RemoteQueries />
};
export default definition;

View File

@@ -1,10 +1,9 @@
import * as React from 'react';
import { WebviewDefinition } from '../webview-interface';
import { WebviewDefinition } from '../webview-definition';
import { ResultsApp } from './results';
const definition: WebviewDefinition = {
component: <ResultsApp />,
loadedMessage: 'resultViewLoaded'
component: <ResultsApp />
};
export default definition;

View File

@@ -12,7 +12,8 @@
"noUnusedLocals": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true
"experimentalDecorators": true,
"skipLibCheck": true
},
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
},
"exclude": []
}

View File

@@ -0,0 +1,8 @@
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
import styled from 'styled-components';
export const LinkIconButton = styled(VSCodeLink)`
.codicon {
vertical-align: text-bottom;
}
`;

View File

@@ -0,0 +1,44 @@
import * as React from 'react';
import styled from 'styled-components';
import { ViewTitle } from '../common';
import { LinkIconButton } from './LinkIconButton';
export type QueryDetailsProps = {
queryName: string;
queryFileName: string;
onOpenQueryFileClick: () => void;
onViewQueryTextClick: () => void;
};
const Container = styled.div`
max-width: 100%;
`;
const QueryActions = styled.div`
display: flex;
gap: 1em;
`;
export const QueryDetails = ({
queryName,
queryFileName,
onOpenQueryFileClick,
onViewQueryTextClick,
}: QueryDetailsProps) => {
return (
<Container>
<ViewTitle>{queryName}</ViewTitle>
<QueryActions>
<LinkIconButton onClick={onOpenQueryFileClick}>
<span slot="start" className="codicon codicon-file-code"></span>
{queryFileName}
</LinkIconButton>
<LinkIconButton onClick={onViewQueryTextClick}>
<span slot="start" className="codicon codicon-code"></span>
View query
</LinkIconButton>
</QueryActions>
</Container>
);
};

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import type { ReactNode } from 'react';
import styled from 'styled-components';
type Props = {
title: ReactNode;
children: ReactNode;
};
const Container = styled.div`
flex: 1;
`;
const Header = styled.div`
color: var(--vscode-badge-foreground);
font-size: 0.85em;
font-weight: 800;
text-transform: uppercase;
margin-bottom: 0.6em;
`;
export const StatItem = ({ title, children }: Props) => (
<Container>
<Header>{title}</Header>
<div>{children}</div>
</Container>
);

View File

@@ -0,0 +1,120 @@
import * as React from 'react';
import {
VariantAnalysis as VariantAnalysisDomainModel,
VariantAnalysisQueryLanguage,
VariantAnalysisRepoStatus,
VariantAnalysisStatus
} from '../../remote-queries/shared/variant-analysis';
import { VariantAnalysisContainer } from './VariantAnalysisContainer';
import { VariantAnalysisHeader } from './VariantAnalysisHeader';
const variantAnalysis: VariantAnalysisDomainModel = {
id: 1,
controllerRepoId: 1,
query: {
name: 'Example query',
filePath: 'example.ql',
language: VariantAnalysisQueryLanguage.Javascript,
},
databases: {},
status: VariantAnalysisStatus.InProgress,
scannedRepos: [
{
repository: {
id: 1,
fullName: 'octodemo/hello-world-1',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 2,
fullName: 'octodemo/hello-world-2',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 3,
fullName: 'octodemo/hello-world-3',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 4,
fullName: 'octodemo/hello-world-4',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 5,
fullName: 'octodemo/hello-world-5',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 6,
fullName: 'octodemo/hello-world-6',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 7,
fullName: 'octodemo/hello-world-7',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 8,
fullName: 'octodemo/hello-world-8',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 9,
fullName: 'octodemo/hello-world-9',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
repository: {
id: 10,
fullName: 'octodemo/hello-world-10',
private: false,
},
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
]
};
export function VariantAnalysis(): JSX.Element {
return (
<VariantAnalysisContainer>
<VariantAnalysisHeader
variantAnalysis={variantAnalysis}
onOpenQueryFileClick={() => console.log('Open query')}
onViewQueryTextClick={() => console.log('View query')}
onStopQueryClick={() => console.log('Stop query')}
onCopyRepositoryListClick={() => console.log('Copy repository list')}
onExportResultsClick={() => console.log('Export results')}
onViewLogsClick={() => console.log('View logs')}
/>
</VariantAnalysisContainer>
);
}

View File

@@ -0,0 +1,50 @@
import * as React from 'react';
import styled from 'styled-components';
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
type Props = {
variantAnalysisStatus: VariantAnalysisStatus;
onStopQueryClick: () => void;
onCopyRepositoryListClick: () => void;
onExportResultsClick: () => void;
};
const Container = styled.div`
margin-left: auto;
display: flex;
gap: 1em;
`;
const Button = styled(VSCodeButton)`
white-space: nowrap;
`;
export const VariantAnalysisActions = ({
variantAnalysisStatus,
onStopQueryClick,
onCopyRepositoryListClick,
onExportResultsClick
}: Props) => {
return (
<Container>
{variantAnalysisStatus === VariantAnalysisStatus.InProgress && (
<Button appearance="secondary" onClick={onStopQueryClick}>
Stop query
</Button>
)}
{variantAnalysisStatus === VariantAnalysisStatus.Succeeded && (
<>
<Button appearance="secondary" onClick={onCopyRepositoryListClick}>
Copy repository list
</Button>
<Button appearance="primary" onClick={onExportResultsClick}>
Export results
</Button>
</>
)}
</Container>
);
};

View File

@@ -0,0 +1,5 @@
import styled from 'styled-components';
export const VariantAnalysisContainer = styled.div`
max-width: 55em;
`;

View File

@@ -0,0 +1,93 @@
import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components';
import {
getSkippedRepoCount, getTotalResultCount,
hasRepoScanCompleted,
VariantAnalysis,
} from '../../remote-queries/shared/variant-analysis';
import { QueryDetails } from './QueryDetails';
import { VariantAnalysisActions } from './VariantAnalysisActions';
import { VariantAnalysisStats } from './VariantAnalysisStats';
export type VariantAnalysisHeaderProps = {
variantAnalysis: VariantAnalysis;
duration?: number | undefined;
completedAt?: Date | undefined;
onOpenQueryFileClick: () => void;
onViewQueryTextClick: () => void;
onStopQueryClick: () => void;
onCopyRepositoryListClick: () => void;
onExportResultsClick: () => void;
onViewLogsClick: () => void;
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 2em;
`;
const Row = styled.div`
display: flex;
align-items: center;
`;
export const VariantAnalysisHeader = ({
variantAnalysis,
duration,
completedAt,
onOpenQueryFileClick,
onViewQueryTextClick,
onStopQueryClick,
onCopyRepositoryListClick,
onExportResultsClick,
onViewLogsClick,
}: VariantAnalysisHeaderProps) => {
const totalScannedRepositoryCount = useMemo(() => {
return variantAnalysis.scannedRepos?.length ?? 0;
}, [variantAnalysis.scannedRepos]);
const completedRepositoryCount = useMemo(() => {
return variantAnalysis.scannedRepos?.filter(repo => hasRepoScanCompleted(repo))?.length ?? 0;
}, [variantAnalysis.scannedRepos]);
const resultCount = useMemo(() => {
return getTotalResultCount(variantAnalysis.scannedRepos);
}, [variantAnalysis.scannedRepos]);
const hasSkippedRepos = useMemo(() => {
return getSkippedRepoCount(variantAnalysis.skippedRepos) > 0;
}, [variantAnalysis.skippedRepos]);
return (
<Container>
<Row>
<QueryDetails
queryName={variantAnalysis.query.name}
queryFileName={variantAnalysis.query.filePath}
onOpenQueryFileClick={onOpenQueryFileClick}
onViewQueryTextClick={onViewQueryTextClick}
/>
<VariantAnalysisActions
variantAnalysisStatus={variantAnalysis.status}
onStopQueryClick={onStopQueryClick}
onCopyRepositoryListClick={onCopyRepositoryListClick}
onExportResultsClick={onExportResultsClick}
/>
</Row>
<VariantAnalysisStats
variantAnalysisStatus={variantAnalysis.status}
totalRepositoryCount={totalScannedRepositoryCount}
completedRepositoryCount={completedRepositoryCount}
resultCount={resultCount}
hasWarnings={hasSkippedRepos}
duration={duration}
completedAt={completedAt}
onViewLogsClick={onViewLogsClick}
/>
</Container>
);
};

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import { formatDecimal } from '../../pure/number';
import { ErrorIcon, HorizontalSpace, SuccessIcon, WarningIcon } from '../common';
type Props = {
variantAnalysisStatus: VariantAnalysisStatus;
totalRepositoryCount: number;
completedRepositoryCount?: number | undefined;
showWarning?: boolean;
};
export const VariantAnalysisRepositoriesStats = ({
variantAnalysisStatus,
totalRepositoryCount,
completedRepositoryCount = 0,
showWarning,
}: Props) => {
if (variantAnalysisStatus === VariantAnalysisStatus.Failed) {
return (
<>
0<HorizontalSpace size={2} /><ErrorIcon />
</>
);
}
return (
<>
{formatDecimal(completedRepositoryCount)}/{formatDecimal(totalRepositoryCount)}
{showWarning && <><HorizontalSpace size={2} /><WarningIcon /></>}
{!showWarning && variantAnalysisStatus === VariantAnalysisStatus.Succeeded &&
<><HorizontalSpace size={2} /><SuccessIcon label="Completed" /></>}
</>
);
};

View File

@@ -0,0 +1,86 @@
import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import { StatItem } from './StatItem';
import { formatDecimal } from '../../pure/number';
import { humanizeUnit } from '../../pure/time';
import { VariantAnalysisRepositoriesStats } from './VariantAnalysisRepositoriesStats';
import { VariantAnalysisStatusStats } from './VariantAnalysisStatusStats';
export type VariantAnalysisStatsProps = {
variantAnalysisStatus: VariantAnalysisStatus;
totalRepositoryCount: number;
completedRepositoryCount?: number | undefined;
hasWarnings?: boolean;
resultCount?: number | undefined;
duration?: number | undefined;
completedAt?: Date | undefined;
onViewLogsClick: () => void;
};
const Row = styled.div`
display: flex;
width: 100%;
gap: 1em;
`;
export const VariantAnalysisStats = ({
variantAnalysisStatus,
totalRepositoryCount,
completedRepositoryCount = 0,
hasWarnings,
resultCount,
duration,
completedAt,
onViewLogsClick,
}: VariantAnalysisStatsProps) => {
const completionHeaderName = useMemo(() => {
if (variantAnalysisStatus === VariantAnalysisStatus.InProgress) {
return 'Running';
}
if (variantAnalysisStatus === VariantAnalysisStatus.Failed) {
return 'Failed';
}
if (variantAnalysisStatus === VariantAnalysisStatus.Canceled) {
return 'Stopped';
}
if (variantAnalysisStatus === VariantAnalysisStatus.Succeeded && hasWarnings) {
return 'Succeeded warnings';
}
return 'Succeeded';
}, [variantAnalysisStatus, hasWarnings]);
return (
<Row>
<StatItem title="Results">
{resultCount !== undefined ? formatDecimal(resultCount) : '-'}
</StatItem>
<StatItem title="Repositories">
<VariantAnalysisRepositoriesStats
variantAnalysisStatus={variantAnalysisStatus}
totalRepositoryCount={totalRepositoryCount}
completedRepositoryCount={completedRepositoryCount}
showWarning={hasWarnings}
/>
</StatItem>
<StatItem title="Duration">
{duration !== undefined ? humanizeUnit(duration) : '-'}
</StatItem>
<StatItem title={completionHeaderName}>
<VariantAnalysisStatusStats
completedAt={completedAt}
onViewLogsClick={onViewLogsClick}
/>
</StatItem>
</Row>
);
};

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import styled from 'styled-components';
import { VSCodeLink } from '@vscode/webview-ui-toolkit/react';
import { formatDate } from '../../pure/date';
type Props = {
completedAt?: Date | undefined;
onViewLogsClick: () => void;
};
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 0.5em;
`;
const Icon = styled.span`
font-size: 1em !important;
vertical-align: text-bottom;
`;
export const VariantAnalysisStatusStats = ({
completedAt,
onViewLogsClick,
}: Props) => {
if (completedAt === undefined) {
return <Icon className="codicon codicon-loading codicon-modifier-spin" />;
}
return (
<Container>
<span>{formatDate(completedAt)}</span>
<VSCodeLink onClick={onViewLogsClick}>View logs</VSCodeLink>
</Container>
);
};

View File

@@ -0,0 +1,51 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryDetails, QueryDetailsProps } from '../QueryDetails';
describe(QueryDetails.name, () => {
const onOpenQueryFileClick = jest.fn();
const onViewQueryTextClick = jest.fn();
const onStopQueryClick = jest.fn();
const onCopyRepositoryListClick = jest.fn();
const onExportResultsClick = jest.fn();
afterEach(() => {
onOpenQueryFileClick.mockReset();
onViewQueryTextClick.mockReset();
onStopQueryClick.mockReset();
onCopyRepositoryListClick.mockReset();
onExportResultsClick.mockReset();
});
const render = (props: Partial<QueryDetailsProps> = {}) =>
reactRender(
<QueryDetails
queryName="Query name"
queryFileName="example.ql"
onOpenQueryFileClick={onOpenQueryFileClick}
onViewQueryTextClick={onViewQueryTextClick}
{...props}
/>
);
it('renders correctly', () => {
render();
expect(screen.getByText('Query name')).toBeInTheDocument();
});
it('renders the query file name as a button', async () => {
render();
await userEvent.click(screen.getByText('example.ql'));
expect(onOpenQueryFileClick).toHaveBeenCalledTimes(1);
});
it('renders a view query button', async () => {
render();
await userEvent.click(screen.getByText('View query'));
expect(onViewQueryTextClick).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,66 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VariantAnalysisStatus } from '../../../remote-queries/shared/variant-analysis';
import { VariantAnalysisActions } from '../VariantAnalysisActions';
describe(VariantAnalysisActions.name, () => {
const onStopQueryClick = jest.fn();
const onCopyRepositoryListClick = jest.fn();
const onExportResultsClick = jest.fn();
afterEach(() => {
onStopQueryClick.mockReset();
onCopyRepositoryListClick.mockReset();
onExportResultsClick.mockReset();
});
const render = (variantAnalysisStatus: VariantAnalysisStatus) =>
reactRender(
<VariantAnalysisActions
variantAnalysisStatus={variantAnalysisStatus}
onStopQueryClick={onStopQueryClick}
onCopyRepositoryListClick={onCopyRepositoryListClick}
onExportResultsClick={onExportResultsClick}
/>
);
it('renders 1 button when in progress', async () => {
const { container } = render(VariantAnalysisStatus.InProgress);
expect(container.querySelectorAll('vscode-button').length).toEqual(1);
});
it('renders the stop query button when in progress', async () => {
render(VariantAnalysisStatus.InProgress);
await userEvent.click(screen.getByText('Stop query'));
expect(onStopQueryClick).toHaveBeenCalledTimes(1);
});
it('renders 2 buttons when succeeded', async () => {
const { container } = render(VariantAnalysisStatus.Succeeded);
expect(container.querySelectorAll('vscode-button').length).toEqual(2);
});
it('renders the copy repository list button when succeeded', async () => {
render(VariantAnalysisStatus.Succeeded);
await userEvent.click(screen.getByText('Copy repository list'));
expect(onCopyRepositoryListClick).toHaveBeenCalledTimes(1);
});
it('renders the export results button when succeeded', async () => {
render(VariantAnalysisStatus.Succeeded);
await userEvent.click(screen.getByText('Export results'));
expect(onExportResultsClick).toHaveBeenCalledTimes(1);
});
it('does not render any buttons when failed', () => {
const { container } = render(VariantAnalysisStatus.Failed);
expect(container.querySelectorAll('vscode-button').length).toEqual(0);
});
});

View File

@@ -0,0 +1,103 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import { VariantAnalysisStatus } from '../../../remote-queries/shared/variant-analysis';
import { VariantAnalysisStats, VariantAnalysisStatsProps } from '../VariantAnalysisStats';
import { userEvent } from '@storybook/testing-library';
describe(VariantAnalysisStats.name, () => {
const onViewLogsClick = jest.fn();
afterEach(() => {
onViewLogsClick.mockReset();
});
const render = (props: Partial<VariantAnalysisStatsProps> = {}) =>
reactRender(
<VariantAnalysisStats
variantAnalysisStatus={VariantAnalysisStatus.InProgress}
totalRepositoryCount={10}
onViewLogsClick={onViewLogsClick}
{...props}
/>
);
it('renders correctly', () => {
render();
expect(screen.getByText('Results')).toBeInTheDocument();
});
it('renders the number of results as a formatted number', () => {
render({ resultCount: 123456 });
expect(screen.getByText('123,456')).toBeInTheDocument();
});
it('renders the number of repositories as a formatted number', () => {
render({ totalRepositoryCount: 123456, completedRepositoryCount: 654321 });
expect(screen.getByText('654,321/123,456')).toBeInTheDocument();
});
it('renders a warning icon when has warnings is set', () => {
render({ hasWarnings: true });
expect(screen.getByRole('img', {
name: 'Warning',
})).toBeInTheDocument();
});
it('renders an error icon when the variant analysis status is failed', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Failed });
expect(screen.getByRole('img', {
name: 'Error',
})).toBeInTheDocument();
});
it('renders a completed icon when the variant analysis status is succeeded', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Succeeded });
expect(screen.getByRole('img', {
name: 'Completed',
})).toBeInTheDocument();
});
it('renders a view logs link when the variant analysis status is succeeded', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Succeeded, completedAt: new Date() });
userEvent.click(screen.getByText('View logs'));
expect(onViewLogsClick).toHaveBeenCalledTimes(1);
});
it('renders a running text when the variant analysis status is in progress', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.InProgress });
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('renders a failed text when the variant analysis status is failed', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Failed });
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('renders a stopped text when the variant analysis status is canceled', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Canceled });
expect(screen.getByText('Stopped')).toBeInTheDocument();
});
it('renders a succeeded warnings text when the variant analysis status is succeeded and has warnings', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Succeeded, hasWarnings: true });
expect(screen.getByText('Succeeded warnings')).toBeInTheDocument();
});
it('renders a succeeded text when the variant analysis status is succeeded', () => {
render({ variantAnalysisStatus: VariantAnalysisStatus.Succeeded });
expect(screen.getByText('Succeeded')).toBeInTheDocument();
expect(screen.queryByText('Succeeded warnings')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,9 @@
import * as React from 'react';
import { WebviewDefinition } from '../webview-definition';
import { VariantAnalysis } from './VariantAnalysis';
const definition: WebviewDefinition = {
component: <VariantAnalysis />
};
export default definition;

View File

@@ -0,0 +1,3 @@
export type WebviewDefinition = {
component: JSX.Element;
}

View File

@@ -1,4 +0,0 @@
export type WebviewDefinition = {
component: JSX.Element,
loadedMessage: 'compareViewLoaded' | 'remoteQueryLoaded' | 'resultViewLoaded';
}

View File

@@ -1,7 +1,7 @@
import * as ReactDOM from 'react-dom';
import { vscode } from './vscode-api';
import { WebviewDefinition } from './webview-interface';
import { WebviewDefinition } from './webview-definition';
// Allow all views to use Codicons
import '@vscode/codicons/dist/codicon.css';
@@ -29,7 +29,7 @@ const render = () => {
view.component,
document.getElementById('root'),
// Post a message to the extension when fully loaded.
() => vscode.postMessage({ t: view.loadedMessage })
() => vscode.postMessage({ t: 'viewLoaded', viewName })
);
};

View File

@@ -17,7 +17,7 @@ import { tmpDir } from '../../helpers';
import { getErrorMessage } from '../../pure/helpers-pure';
import { HistoryItemLabelProvider } from '../../history-item-label-provider';
import { RemoteQueriesManager } from '../../remote-queries/remote-queries-manager';
import { InterfaceManager } from '../../interface';
import { ResultsView } from '../../interface';
import { EvalLogViewer } from '../../eval-log-viewer';
import { QueryRunner } from '../../queryRunner';
@@ -31,7 +31,7 @@ describe('query-history', () => {
let queryHistoryManager: QueryHistoryManager | undefined;
let doCompareCallback: sinon.SinonStub;
let localQueriesInterfaceManagerStub: InterfaceManager;
let localQueriesResultsViewStub: ResultsView;
let remoteQueriesManagerStub: RemoteQueriesManager;
let tryOpenExternalFile: Function;
@@ -54,9 +54,9 @@ describe('query-history', () => {
tryOpenExternalFile = (QueryHistoryManager.prototype as any).tryOpenExternalFile;
configListener = new QueryHistoryConfigListener();
doCompareCallback = sandbox.stub();
localQueriesInterfaceManagerStub = {
localQueriesResultsViewStub = {
showResults: sandbox.stub()
} as any as InterfaceManager;
} as any as ResultsView;
remoteQueriesManagerStub = {
onRemoteQueryAdded: sandbox.stub(),
onRemoteQueryRemoved: sandbox.stub(),
@@ -204,7 +204,7 @@ describe('query-history', () => {
await queryHistoryManager.handleItemClicked(allHistory[0], [allHistory[0]]);
expect(localQueriesInterfaceManagerStub.showResults).to.have.been.calledOnceWith(allHistory[0]);
expect(localQueriesResultsViewStub.showResults).to.have.been.calledOnceWith(allHistory[0]);
expect(queryHistoryManager.treeDataProvider.getCurrent()).to.eq(allHistory[0]);
});
@@ -213,7 +213,7 @@ describe('query-history', () => {
await queryHistoryManager.handleItemClicked(allHistory[0], [allHistory[0], allHistory[1]]);
expect(localQueriesInterfaceManagerStub.showResults).not.to.have.been.called;
expect(localQueriesResultsViewStub.showResults).not.to.have.been.called;
expect(queryHistoryManager.treeDataProvider.getCurrent()).to.be.undefined;
});
@@ -222,7 +222,7 @@ describe('query-history', () => {
await queryHistoryManager.handleItemClicked(undefined!, []);
expect(localQueriesInterfaceManagerStub.showResults).not.to.have.been.called;
expect(localQueriesResultsViewStub.showResults).not.to.have.been.called;
expect(queryHistoryManager.treeDataProvider.getCurrent()).to.be.undefined;
});
});
@@ -251,7 +251,7 @@ describe('query-history', () => {
expect(queryHistoryManager.treeDataProvider.allHistory).not.to.contain(toDelete);
// the same item should be selected
expect(localQueriesInterfaceManagerStub.showResults).to.have.been.calledOnceWith(selected);
expect(localQueriesResultsViewStub.showResults).to.have.been.calledOnceWith(selected);
});
it('should remove an item and select a new one', async () => {
@@ -271,7 +271,7 @@ describe('query-history', () => {
expect(queryHistoryManager.treeDataProvider.allHistory).not.to.contain(toDelete);
// the current item should have been selected
expect(localQueriesInterfaceManagerStub.showResults).to.have.been.calledOnceWith(newSelected);
expect(localQueriesResultsViewStub.showResults).to.have.been.calledOnceWith(newSelected);
});
describe('Compare callback', () => {
@@ -794,7 +794,7 @@ describe('query-history', () => {
const qhm = new QueryHistoryManager(
{} as QueryRunner,
{} as DatabaseManager,
localQueriesInterfaceManagerStub,
localQueriesResultsViewStub,
remoteQueriesManagerStub,
{} as EvalLogViewer,
'xxx',

View File

@@ -7,7 +7,7 @@ import { ExtensionContext } from 'vscode';
import { createMockExtensionContext } from '../index';
import { Credentials } from '../../../authentication';
import { MarkdownFile } from '../../../remote-queries/remote-queries-markdown-generation';
import * as actionsApiClient from '../../../remote-queries/gh-actions-api-client';
import * as actionsApiClient from '../../../remote-queries/gh-api/gh-actions-api-client';
import { exportResultsToGist } from '../../../remote-queries/export-results';
const proxyquire = pq.noPreserveCache();

View File

@@ -1,9 +1,9 @@
import { fail } from 'assert';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { Credentials } from '../../../authentication';
import { cancelRemoteQuery, getRepositoriesMetadata } from '../../../remote-queries/gh-actions-api-client';
import { RemoteQuery } from '../../../remote-queries/remote-query';
import { Credentials } from '../../../../authentication';
import { cancelRemoteQuery, getRepositoriesMetadata } from '../../../../remote-queries/gh-api/gh-actions-api-client';
import { RemoteQuery } from '../../../../remote-queries/remote-query';
describe('gh-actions-api-client mock responses', () => {
let sandbox: sinon.SinonSandbox;

View File

@@ -17,7 +17,7 @@ import { walkDirectory } from '../../../helpers';
import { getErrorMessage } from '../../../pure/helpers-pure';
import { HistoryItemLabelProvider } from '../../../history-item-label-provider';
import { RemoteQueriesManager } from '../../../remote-queries/remote-queries-manager';
import { InterfaceManager } from '../../../interface';
import { ResultsView } from '../../../interface';
import { EvalLogViewer } from '../../../eval-log-viewer';
import { QueryRunner } from '../../../queryRunner';
@@ -33,7 +33,7 @@ describe('Remote queries and query history manager', function() {
let sandbox: sinon.SinonSandbox;
let qhm: QueryHistoryManager;
let localQueriesInterfaceManagerStub: InterfaceManager;
let localQueriesResultsViewStub: ResultsView;
let remoteQueriesManagerStub: RemoteQueriesManager;
let rawQueryHistory: any;
let remoteQueryResult0: RemoteQueryResult;
@@ -57,9 +57,9 @@ describe('Remote queries and query history manager', function() {
sandbox = sinon.createSandbox();
localQueriesInterfaceManagerStub = {
localQueriesResultsViewStub = {
showResults: sandbox.stub()
} as any as InterfaceManager;
} as any as ResultsView;
rehydrateRemoteQueryStub = sandbox.stub();
removeRemoteQueryStub = sandbox.stub();
@@ -92,7 +92,7 @@ describe('Remote queries and query history manager', function() {
qhm = new QueryHistoryManager(
{} as QueryRunner,
{} as DatabaseManager,
localQueriesInterfaceManagerStub,
localQueriesResultsViewStub,
remoteQueriesManagerStub,
{} as EvalLogViewer,
STORAGE_DIR,

Some files were not shown because too many files have changed in this diff Show More