Restore variant analysis view on restart of VSCode

This implements persistence for the variant analysis webview, allowing
the webview panel to be restored when VSCode is restarted. It's probably
easier to add this now than to try to add it later.

The basic idea is that there are no real differences when opening the
webview for the first time. However, when VSCode is restarted it will
use the `VariantAnalysisViewSerializer` to restore the webview panel.
In our case this means recreating the `VariantAnalysisView`.

To fully test this, I've also added a mock variant analysis ID as the
state of the webview. This value is now randomly generated when calling
the `codeQL.mockVariantAnalysisView` command. This allows us to test
opening multiple webviews and that the webviews are restored with the
correct state.

See: https://code.visualstudio.com/api/extension-guides/webview#persistence
This commit is contained in:
Koen Vlaswinkel
2022-09-29 13:26:56 +02:00
parent cf3ba32906
commit d8fbc56ec2
10 changed files with 521 additions and 199 deletions

View File

@@ -33,5 +33,6 @@ export const parameters = {
};
(window as any).acquireVsCodeApi = () => ({
postMessage: action('post-vscode-message')
postMessage: action('post-vscode-message'),
setState: action('set-vscode-state'),
});

View File

@@ -63,6 +63,7 @@
"onCommand:codeQL.quickQuery",
"onCommand:codeQL.restartQueryServer",
"onWebviewPanel:resultsView",
"onWebviewPanel:codeQL.variantAnalysis",
"onFileSystem:codeql-zip-archive"
],
"main": "./out/extension",

View File

@@ -33,6 +33,11 @@ export abstract class AbstractWebview<ToMessage extends WebviewMessage, FromMess
super();
}
public async restoreView(panel: WebviewPanel): Promise<void> {
this.panel = panel;
this.setupPanel(panel);
}
protected get isShowingPanel() {
return !!this.panel;
}
@@ -59,37 +64,43 @@ export abstract class AbstractWebview<ToMessage extends WebviewMessage, FromMess
],
}
);
this.push(
this.panel.onDidDispose(
() => {
this.panel = undefined;
this.panelLoaded = false;
this.onPanelDispose();
},
null,
ctx.subscriptions
)
);
this.panel.webview.html = getHtmlForWebview(
ctx,
this.panel.webview,
config.view,
{
allowInlineStyles: true,
}
);
this.push(
this.panel.webview.onDidReceiveMessage(
async (e) => this.onMessage(e),
undefined,
ctx.subscriptions
)
);
this.setupPanel(this.panel);
}
return this.panel;
}
protected setupPanel(panel: WebviewPanel): void {
const config = this.getPanelConfig();
this.push(
panel.onDidDispose(
() => {
this.panel = undefined;
this.panelLoaded = false;
this.onPanelDispose();
},
null,
this.ctx.subscriptions
)
);
panel.webview.html = getHtmlForWebview(
this.ctx,
panel.webview,
config.view,
{
allowInlineStyles: true,
}
);
this.push(
panel.webview.onDidReceiveMessage(
async (e) => this.onMessage(e),
undefined,
this.ctx.subscriptions
)
);
}
protected abstract getPanelConfig(): WebviewPanelConfig;
protected abstract onPanelDispose(): void;

View File

@@ -105,6 +105,7 @@ 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';
import { VariantAnalysisViewSerializer } from './remote-queries/variant-analysis-view-serializer';
/**
* extension.ts
@@ -381,7 +382,10 @@ export async function activate(ctx: ExtensionContext): Promise<CodeQLExtensionIn
allowAutoUpdating: true
})));
return await installOrUpdateThenTryActivate({
const variantAnalysisViewSerializer = new VariantAnalysisViewSerializer(ctx);
Window.registerWebviewPanelSerializer(VariantAnalysisView.viewType, variantAnalysisViewSerializer);
const codeQlExtension = await installOrUpdateThenTryActivate({
isUserInitiated: !!ctx.globalState.get(shouldUpdateOnNextActivationKey),
shouldDisplayMessageWhenNoUpdates: false,
@@ -389,6 +393,10 @@ export async function activate(ctx: ExtensionContext): Promise<CodeQLExtensionIn
// otherwise, ask user to accept the update
allowAutoUpdating: !!ctx.globalState.get(shouldUpdateOnNextActivationKey)
});
variantAnalysisViewSerializer.onExtensionLoaded();
return codeQlExtension;
}
async function activateWithInstalledDistribution(
@@ -909,8 +917,11 @@ async function activateWithInstalledDistribution(
ctx.subscriptions.push(
commandRunner('codeQL.mockVariantAnalysisView', async () => {
const variantAnalysisView = new VariantAnalysisView(ctx);
variantAnalysisView.openView();
// Generate a random variant analysis ID for testing
const variantAnalysisId: number = Math.floor(Math.random() * 1000000);
const variantAnalysisView = new VariantAnalysisView(ctx, variantAnalysisId);
void variantAnalysisView.openView();
})
);

View File

@@ -2,6 +2,7 @@ import * as sarif from 'sarif';
import { AnalysisResults } from '../remote-queries/shared/analysis-result';
import { AnalysisSummary, RemoteQueryResult } from '../remote-queries/shared/remote-query-result';
import { RawResultSet, ResultRow, ResultSetSchema, Column, ResolvableLocationValue } from './bqrs-cli-types';
import { VariantAnalysis } from '../remote-queries/shared/variant-analysis';
/**
* This module contains types and code that are shared between
@@ -429,3 +430,24 @@ export interface CopyRepoListMessage {
t: 'copyRepoList';
queryId: string;
}
export interface SetVariantAnalysisMessage {
t: 'setVariantAnalysis';
variantAnalysis: VariantAnalysis;
}
export type ToVariantAnalysisMessage =
| SetVariantAnalysisMessage;
export type StopVariantAnalysisMessage = {
t: 'stopVariantAnalysis';
variantAnalysisId: number;
}
export type FromVariantAnalysisMessage =
| ViewLoadedMsg
| StopVariantAnalysisMessage;
export type VariantAnalysisState = {
variantAnalysisId: number;
}

View File

@@ -0,0 +1,44 @@
import { ExtensionContext, WebviewPanel, WebviewPanelSerializer } from 'vscode';
import { VariantAnalysisView } from './variant-analysis-view';
import { VariantAnalysisState } from '../pure/interface-types';
export class VariantAnalysisViewSerializer implements WebviewPanelSerializer {
private extensionLoaded = false;
private readonly resolvePromises: (() => void)[] = [];
public constructor(
private readonly ctx: ExtensionContext
) { }
onExtensionLoaded(): void {
this.extensionLoaded = true;
this.resolvePromises.forEach((resolve) => resolve());
}
async deserializeWebviewPanel(webviewPanel: WebviewPanel, state: unknown): Promise<void> {
if (!state || typeof state !== 'object') {
return;
}
if (!('variantAnalysisId' in state)) {
return;
}
const variantAnalysisState: VariantAnalysisState = state as VariantAnalysisState;
await this.waitForExtensionFullyLoaded();
const view = new VariantAnalysisView(this.ctx, variantAnalysisState.variantAnalysisId);
await view.restoreView(webviewPanel);
}
private waitForExtensionFullyLoaded(): Promise<void> {
if (this.extensionLoaded) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.resolvePromises.push(resolve);
});
}
}

View File

@@ -1,20 +1,38 @@
import { ViewColumn } from 'vscode';
import { ExtensionContext, ViewColumn } from 'vscode';
import { AbstractWebview, WebviewPanelConfig } from '../abstract-webview';
import { WebviewMessage } from '../interface-utils';
import { logger } from '../logging';
import { FromVariantAnalysisMessage, ToVariantAnalysisMessage } from '../pure/interface-types';
import { assertNever } from '../pure/helpers-pure';
import {
VariantAnalysis,
VariantAnalysisQueryLanguage,
VariantAnalysisRepoStatus,
VariantAnalysisStatus
} from './shared/variant-analysis';
export class VariantAnalysisView extends AbstractWebview<WebviewMessage, WebviewMessage> {
public openView() {
export class VariantAnalysisView extends AbstractWebview<ToVariantAnalysisMessage, FromVariantAnalysisMessage> {
public static readonly viewType = 'codeQL.variantAnalysis';
public constructor(
ctx: ExtensionContext,
private readonly variantAnalysisId: number
) {
super(ctx);
}
public async openView() {
this.getPanel().reveal(undefined, true);
await this.waitForPanelLoaded();
}
protected getPanelConfig(): WebviewPanelConfig {
return {
viewId: 'variantAnalysisView',
title: 'CodeQL Query Results',
viewId: VariantAnalysisView.viewType,
title: `CodeQL query results for query ${this.variantAnalysisId}`,
viewColumn: ViewColumn.Active,
preserveFocus: true,
view: 'variant-analysis'
view: 'variant-analysis',
};
}
@@ -22,7 +40,173 @@ export class VariantAnalysisView extends AbstractWebview<WebviewMessage, Webview
// Nothing to dispose currently.
}
protected async onMessage(msg: WebviewMessage): Promise<void> {
void logger.log('Received message on variant analysis view: ' + msg.t);
protected async onMessage(msg: FromVariantAnalysisMessage): Promise<void> {
switch (msg.t) {
case 'viewLoaded':
this.onWebViewLoaded();
void logger.log('Variant analysis view loaded');
await this.postMessage({
t: 'setVariantAnalysis',
variantAnalysis: this.getVariantAnalysis(),
});
break;
case 'stopVariantAnalysis':
void logger.log(`Stop variant analysis: ${msg.variantAnalysisId}`);
break;
default:
assertNever(msg);
}
}
private getVariantAnalysis(): VariantAnalysis {
return {
id: this.variantAnalysisId,
controllerRepoId: 1,
actionsWorkflowRunId: 789263,
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,
},
],
skippedRepos: {
notFoundRepos: {
repositoryCount: 2,
repositories: [
{
fullName: 'octodemo/hello-globe'
},
{
fullName: 'octodemo/hello-planet'
}
]
},
noCodeqlDbRepos: {
repositoryCount: 4,
repositories: [
{
id: 100,
fullName: 'octodemo/no-db-1'
},
{
id: 101,
fullName: 'octodemo/no-db-2'
},
{
id: 102,
fullName: 'octodemo/no-db-3'
},
{
id: 103,
fullName: 'octodemo/no-db-4'
}
]
},
overLimitRepos: {
repositoryCount: 1,
repositories: [
{
id: 201,
fullName: 'octodemo/over-limit-1'
}
]
},
accessMismatchRepos: {
repositoryCount: 1,
repositories: [
{
id: 205,
fullName: 'octodemo/private'
}
]
}
},
};
}
}

View File

@@ -3,14 +3,171 @@ import React from 'react';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { VariantAnalysis as VariantAnalysisComponent } from '../../view/variant-analysis/VariantAnalysis';
import {
VariantAnalysis as VariantAnalysisDomainModel,
VariantAnalysisQueryLanguage, VariantAnalysisRepoStatus, VariantAnalysisStatus
} from '../../remote-queries/shared/variant-analysis';
export default {
title: 'Variant Analysis/Variant Analysis',
component: VariantAnalysisComponent,
} as ComponentMeta<typeof VariantAnalysisComponent>;
const Template: ComponentStory<typeof VariantAnalysisComponent> = () => (
<VariantAnalysisComponent />
const Template: ComponentStory<typeof VariantAnalysisComponent> = (args) => (
<VariantAnalysisComponent {...args} />
);
export const VariantAnalysis = Template.bind({});
const variantAnalysis: VariantAnalysisDomainModel = {
id: 1,
controllerRepoId: 1,
actionsWorkflowRunId: 789263,
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,
},
],
skippedRepos: {
notFoundRepos: {
repositoryCount: 2,
repositories: [
{
fullName: 'octodemo/hello-globe'
},
{
fullName: 'octodemo/hello-planet'
}
]
},
noCodeqlDbRepos: {
repositoryCount: 4,
repositories: [
{
id: 100,
fullName: 'octodemo/no-db-1'
},
{
id: 101,
fullName: 'octodemo/no-db-2'
},
{
id: 102,
fullName: 'octodemo/no-db-3'
},
{
id: 103,
fullName: 'octodemo/no-db-4'
}
]
},
overLimitRepos: {
repositoryCount: 1,
repositories: [
{
id: 201,
fullName: 'octodemo/over-limit-1'
}
]
},
accessMismatchRepos: {
repositoryCount: 1,
repositories: [
{
id: 205,
fullName: 'octodemo/private'
}
]
}
},
};
export const Loading = Template.bind({});
Loading.args = {};
export const FullExample = Template.bind({});
FullExample.args = {
variantAnalysis: variantAnalysis,
};

View File

@@ -1,162 +1,13 @@
import * as React from 'react';
import { useEffect, useState } from 'react';
import {
VariantAnalysis as VariantAnalysisDomainModel,
VariantAnalysisQueryLanguage,
VariantAnalysisRepoStatus,
VariantAnalysisStatus
} from '../../remote-queries/shared/variant-analysis';
import { VariantAnalysis as VariantAnalysisDomainModel } from '../../remote-queries/shared/variant-analysis';
import { VariantAnalysisContainer } from './VariantAnalysisContainer';
import { VariantAnalysisHeader } from './VariantAnalysisHeader';
import { VariantAnalysisOutcomePanels } from './VariantAnalysisOutcomePanels';
import { VariantAnalysisLoading } from './VariantAnalysisLoading';
const variantAnalysis: VariantAnalysisDomainModel = {
id: 1,
controllerRepoId: 1,
actionsWorkflowRunId: 789263,
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,
},
],
skippedRepos: {
notFoundRepos: {
repositoryCount: 2,
repositories: [
{
fullName: 'octodemo/hello-globe'
},
{
fullName: 'octodemo/hello-planet'
}
]
},
noCodeqlDbRepos: {
repositoryCount: 4,
repositories: [
{
id: 100,
fullName: 'octodemo/no-db-1'
},
{
id: 101,
fullName: 'octodemo/no-db-2'
},
{
id: 102,
fullName: 'octodemo/no-db-3'
},
{
id: 103,
fullName: 'octodemo/no-db-4'
}
]
},
overLimitRepos: {
repositoryCount: 1,
repositories: [
{
id: 201,
fullName: 'octodemo/over-limit-1'
}
]
},
accessMismatchRepos: {
repositoryCount: 1,
repositories: [
{
id: 205,
fullName: 'octodemo/private'
}
]
}
},
};
import { ToVariantAnalysisMessage } from '../../pure/interface-types';
import { vscode } from '../vscode-api';
function getContainerContents(variantAnalysis: VariantAnalysisDomainModel) {
if (variantAnalysis.actionsWorkflowRunId === undefined) {
@@ -179,7 +30,37 @@ function getContainerContents(variantAnalysis: VariantAnalysisDomainModel) {
);
}
export function VariantAnalysis(): JSX.Element {
type Props = {
variantAnalysis?: VariantAnalysisDomainModel;
}
export function VariantAnalysis({
variantAnalysis: initialVariantAnalysis,
}: Props): JSX.Element {
const [variantAnalysis, setVariantAnalysis] = useState<VariantAnalysisDomainModel | undefined>(initialVariantAnalysis);
useEffect(() => {
window.addEventListener('message', (evt: MessageEvent) => {
if (evt.origin === window.origin) {
const msg: ToVariantAnalysisMessage = evt.data;
if (msg.t === 'setVariantAnalysis') {
setVariantAnalysis(msg.variantAnalysis);
vscode.setState({
variantAnalysisId: msg.variantAnalysis.id,
});
}
} else {
// sanitize origin
const origin = evt.origin.replace(/\n|\r/g, '');
console.error(`Invalid event origin ${origin}`);
}
});
});
if (!variantAnalysis) {
return <VariantAnalysisLoading />;
}
return (
<VariantAnalysisContainer>
{getContainerContents(variantAnalysis)}

View File

@@ -1,10 +1,20 @@
import { FromCompareViewMessage, FromRemoteQueriesMessage, FromResultsViewMsg } from '../pure/interface-types';
import {
FromCompareViewMessage,
FromRemoteQueriesMessage,
FromResultsViewMsg,
FromVariantAnalysisMessage, VariantAnalysisState
} from '../pure/interface-types';
export interface VsCodeApi {
/**
* Post message back to vscode extension.
*/
postMessage(msg: FromResultsViewMsg | FromCompareViewMessage | FromRemoteQueriesMessage): void;
postMessage(msg: FromResultsViewMsg | FromCompareViewMessage | FromRemoteQueriesMessage | FromVariantAnalysisMessage): void;
/**
* Set state of the webview.
*/
setState(state: VariantAnalysisState): void;
}
declare const acquireVsCodeApi: () => VsCodeApi;