Merge remote-tracking branch 'origin/main' into koesie10/filter-export-copy

This commit is contained in:
Koen Vlaswinkel
2022-11-15 10:53:58 +01:00
15 changed files with 233 additions and 23 deletions

View File

@@ -2,4 +2,7 @@ import { AppEventEmitter } from './events';
export interface App {
createEventEmitter<T>(): AppEventEmitter<T>;
extensionPath: string;
globalStoragePath: string;
workspaceStoragePath?: string;
}

View File

@@ -1,8 +1,26 @@
import * as vscode from 'vscode';
import { App } from '../app';
import { AppEventEmitter } from '../events';
import { VSCodeAppEventEmitter } from './events';
export class ExtensionApp implements App {
public constructor(
public readonly extensionContext: vscode.ExtensionContext
) {
}
public get extensionPath(): string {
return this.extensionContext.extensionPath;
}
public get globalStoragePath(): string {
return this.extensionContext.globalStorageUri.fsPath;
}
public get workspaceStoragePath(): string | undefined {
return this.extensionContext.storageUri?.fsPath;
}
public createEventEmitter<T>(): AppEventEmitter<T> {
return new VSCodeAppEventEmitter<T>();
}

View File

@@ -5,6 +5,7 @@ import * as chokidar from 'chokidar';
import { DisposableObject } from '../pure/disposable-object';
import { DbConfigValidator } from './db-config-validator';
import { ValueResult } from '../common/value-result';
import { App } from '../common/app';
export class DbConfigStore extends DisposableObject {
private readonly configPath: string;
@@ -14,17 +15,16 @@ export class DbConfigStore extends DisposableObject {
private configErrors: string[];
private configWatcher: chokidar.FSWatcher | undefined;
public constructor(
workspaceStoragePath: string,
extensionPath: string) {
public constructor(app: App) {
super();
this.configPath = path.join(workspaceStoragePath, 'workspace-databases.json');
const storagePath = app.workspaceStoragePath || app.globalStoragePath;
this.configPath = path.join(storagePath, 'workspace-databases.json');
this.config = this.createEmptyConfig();
this.configErrors = [];
this.configWatcher = undefined;
this.configValidator = new DbConfigValidator(extensionPath);
this.configValidator = new DbConfigValidator(app.extensionPath);
}
public async initialize(): Promise<void> {

View File

@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { ExtensionApp } from '../common/vscode/vscode-app';
import { isCanary, isNewQueryRunExperienceEnabled } from '../config';
import { logger } from '../logging';
import { DisposableObject } from '../pure/disposable-object';
@@ -21,9 +22,9 @@ export class DbModule extends DisposableObject {
void logger.log('Initializing database module');
const storagePath = extensionContext.storageUri?.fsPath || extensionContext.globalStorageUri.fsPath;
const extensionPath = extensionContext.extensionPath;
const dbConfigStore = new DbConfigStore(storagePath, extensionPath);
const app = new ExtensionApp(extensionContext);
const dbConfigStore = new DbConfigStore(app);
await dbConfigStore.initialize();
const dbManager = new DbManager(dbConfigStore);

View File

@@ -15,8 +15,11 @@ import {
*/
export class DbTreeViewItem extends vscode.TreeItem {
constructor(
// iconPath and tooltip must have those names because
// they are part of the vscode.TreeItem interface
public readonly dbItem: DbItem | undefined,
public readonly icon: vscode.ThemeIcon | undefined,
public readonly iconPath: vscode.ThemeIcon | undefined,
public readonly label: string,
public readonly tooltip: string | undefined,
public readonly collapsibleState: vscode.TreeItemCollapsibleState,

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { VSCodeBadge, VSCodeCheckbox } from '@vscode/webview-ui-toolkit/react';
import {
@@ -80,6 +80,9 @@ export type RepoRowProps = {
interpretedResults?: AnalysisAlert[];
rawResults?: AnalysisRawResults;
selected?: boolean;
onSelectedChange?: (repositoryId: number, selected: boolean) => void;
}
const canExpand = (
@@ -101,6 +104,11 @@ const canExpand = (
return downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.Succeeded || downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.Failed;
};
const canSelect = (
status: VariantAnalysisRepoStatus | undefined,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus | undefined,
) => status == VariantAnalysisRepoStatus.Succeeded && downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.Succeeded;
const isExpandableContentLoaded = (
status: VariantAnalysisRepoStatus | undefined,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus | undefined,
@@ -133,6 +141,8 @@ export const RepoRow = ({
resultCount,
interpretedResults,
rawResults,
selected,
onSelectedChange,
}: RepoRowProps) => {
const [isExpanded, setExpanded] = useState(false);
const resultsLoaded = !!interpretedResults || !!rawResults;
@@ -163,13 +173,35 @@ export const RepoRow = ({
}
}, [resultsLoaded, resultsLoading]);
const onClickCheckbox = useCallback((e: React.MouseEvent) => {
// Prevent calling the onClick event of the container, which would toggle the expanded state
e.stopPropagation();
}, []);
const onChangeCheckbox = useCallback((e: ChangeEvent<HTMLInputElement>) => {
// This is called on first render, but we don't really care about this value
if (e.target.checked === undefined) {
return;
}
if (!repository.id) {
return;
}
onSelectedChange?.(repository.id, e.target.checked);
}, [onSelectedChange, repository]);
const disabled = !canExpand(status, downloadStatus);
const expandableContentLoaded = isExpandableContentLoaded(status, downloadStatus, resultsLoaded);
return (
<div>
<TitleContainer onClick={toggleExpanded} disabled={disabled} aria-expanded={isExpanded}>
<VSCodeCheckbox disabled />
<VSCodeCheckbox
onChange={onChangeCheckbox}
onClick={onClickCheckbox}
checked={selected}
disabled={!repository.id || !canSelect(status, downloadStatus)}
/>
{isExpanded ? <ExpandCollapseCodicon name="chevron-down" label="Collapse" /> :
<ExpandCollapseCodicon name="chevron-right" label="Expand" />}
<VSCodeBadge>{resultCount === undefined ? '-' : formatDecimal(resultCount)}</VSCodeBadge>

View File

@@ -52,6 +52,7 @@ export function VariantAnalysis({
const [repoStates, setRepoStates] = useState<VariantAnalysisScannedRepositoryState[]>(initialRepoStates);
const [repoResults, setRepoResults] = useState<VariantAnalysisScannedRepositoryResult[]>(initialRepoResults);
const [selectedRepositoryIds, setSelectedRepositoryIds] = useState<number[]>([]);
const [filterSortState, setFilterSortState] = useState<RepositoriesFilterSortState>(defaultFilterSortState);
useEffect(() => {
@@ -113,6 +114,8 @@ export function VariantAnalysis({
variantAnalysis={variantAnalysis}
repositoryStates={repoStates}
repositoryResults={repoResults}
selectedRepositoryIds={selectedRepositoryIds}
setSelectedRepositoryIds={setSelectedRepositoryIds}
filterSortState={filterSortState}
setFilterSortState={setFilterSortState}
/>

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useMemo } from 'react';
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { RepoRow } from './RepoRow';
import {
@@ -25,6 +25,9 @@ export type VariantAnalysisAnalyzedReposProps = {
repositoryResults?: VariantAnalysisScannedRepositoryResult[];
filterSortState?: RepositoriesFilterSortState;
selectedRepositoryIds?: number[];
setSelectedRepositoryIds?: Dispatch<SetStateAction<number[]>>;
}
export const VariantAnalysisAnalyzedRepos = ({
@@ -32,6 +35,8 @@ export const VariantAnalysisAnalyzedRepos = ({
repositoryStates,
repositoryResults,
filterSortState,
selectedRepositoryIds,
setSelectedRepositoryIds,
}: VariantAnalysisAnalyzedReposProps) => {
const repositoryStateById = useMemo(() => {
const map = new Map<number, VariantAnalysisScannedRepositoryState>();
@@ -53,6 +58,20 @@ export const VariantAnalysisAnalyzedRepos = ({
return filterAndSortRepositoriesWithResults(variantAnalysis.scannedRepos, filterSortState);
}, [filterSortState, variantAnalysis.scannedRepos]);
const onSelectedChange = useCallback((repositoryId: number, selected: boolean) => {
setSelectedRepositoryIds?.((prevSelectedRepositoryIds) => {
if (selected) {
if (prevSelectedRepositoryIds.includes(repositoryId)) {
return prevSelectedRepositoryIds;
}
return [...prevSelectedRepositoryIds, repositoryId];
} else {
return prevSelectedRepositoryIds.filter((id) => id !== repositoryId);
}
});
}, [setSelectedRepositoryIds]);
return (
<Container>
{repositories?.map(repository => {
@@ -68,6 +87,8 @@ export const VariantAnalysisAnalyzedRepos = ({
resultCount={repository.resultCount}
interpretedResults={results?.interpretedResults}
rawResults={results?.rawResults}
selected={selectedRepositoryIds?.includes(repository.repository.id)}
onSelectedChange={onSelectedChange}
/>
);
})}

View File

@@ -21,6 +21,9 @@ export type VariantAnalysisOutcomePanelProps = {
repositoryStates?: VariantAnalysisScannedRepositoryState[];
repositoryResults?: VariantAnalysisScannedRepositoryResult[];
selectedRepositoryIds?: number[];
setSelectedRepositoryIds?: Dispatch<SetStateAction<number[]>>;
filterSortState: RepositoriesFilterSortState;
setFilterSortState: Dispatch<SetStateAction<RepositoriesFilterSortState>>;
};
@@ -49,6 +52,8 @@ export const VariantAnalysisOutcomePanels = ({
variantAnalysis,
repositoryStates,
repositoryResults,
selectedRepositoryIds,
setSelectedRepositoryIds,
filterSortState,
setFilterSortState,
}: VariantAnalysisOutcomePanelProps) => {
@@ -97,6 +102,8 @@ export const VariantAnalysisOutcomePanels = ({
repositoryStates={repositoryStates}
repositoryResults={repositoryResults}
filterSortState={filterSortState}
selectedRepositoryIds={selectedRepositoryIds}
setSelectedRepositoryIds={setSelectedRepositoryIds}
/>
</>
);
@@ -129,6 +136,8 @@ export const VariantAnalysisOutcomePanels = ({
repositoryStates={repositoryStates}
repositoryResults={repositoryResults}
filterSortState={filterSortState}
selectedRepositoryIds={selectedRepositoryIds}
setSelectedRepositoryIds={setSelectedRepositoryIds}
/>
</VSCodePanelView>
{notFoundRepos?.repositoryCount &&

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import { render as reactRender, screen, waitFor } from '@testing-library/react';
import {
VariantAnalysisRepoStatus,
VariantAnalysisScannedRepositoryDownloadStatus
@@ -330,4 +330,42 @@ describe(RepoRow.name, () => {
expanded: false
})).toBeDisabled();
});
it('does not allow selecting the item if the item has not succeeded', async () => {
render({
status: VariantAnalysisRepoStatus.InProgress,
});
expect(screen.getByRole('checkbox')).toBeDisabled();
});
it('does not allow selecting the item if the item has not been downloaded', async () => {
render({
status: VariantAnalysisRepoStatus.Succeeded,
});
expect(screen.getByRole('checkbox')).toBeDisabled();
});
it('does not allow selecting the item if the item has not been downloaded successfully', async () => {
render({
status: VariantAnalysisRepoStatus.Succeeded,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Failed,
});
// It seems like sometimes the first render doesn't have the checkbox disabled
// Might be related to https://github.com/microsoft/vscode-webview-ui-toolkit/issues/404
await waitFor(() => {
expect(screen.getByRole('checkbox')).toBeDisabled();
});
});
it('allows selecting the item if the item has been downloaded', async () => {
render({
status: VariantAnalysisRepoStatus.Succeeded,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Succeeded,
});
expect(screen.getByRole('checkbox')).toBeEnabled();
});
});

View File

@@ -0,0 +1,21 @@
import * as vscode from 'vscode';
/**
* Creates a partially implemented mock of vscode.ExtensionContext.
*/
export function createMockExtensionContext({
extensionPath = '/mock/extension/path',
workspaceStoragePath = '/mock/workspace/storage/path',
globalStoragePath = '/mock/global/storage/path',
}: {
extensionPath?: string,
workspaceStoragePath?: string,
globalStoragePath?: string,
}): vscode.ExtensionContext {
return {
extensionPath: extensionPath,
globalStorageUri: vscode.Uri.file(globalStoragePath),
storageUri: vscode.Uri.file(workspaceStoragePath),
} as any as vscode.ExtensionContext;
}

View File

@@ -10,11 +10,14 @@ import { DbTreeDataProvider } from '../../../databases/ui/db-tree-data-provider'
import { DbPanel } from '../../../databases/ui/db-panel';
import { DbItemKind } from '../../../databases/db-item';
import { DbTreeViewItem } from '../../../databases/ui/db-tree-view-item';
import { ExtensionApp } from '../../../common/vscode/vscode-app';
import { createMockExtensionContext } from '../../factories/extension-context';
const proxyquire = pq.noPreserveCache();
describe('db panel', async () => {
const workspaceStoragePath = path.join(__dirname, 'test-workspace');
const workspaceStoragePath = path.join(__dirname, 'test-workspace-storage');
const globalStoragePath = path.join(__dirname, 'test-global-storage');
const extensionPath = path.join(__dirname, '../../../../');
const dbConfigFilePath = path.join(workspaceStoragePath, 'workspace-databases.json');
let dbTreeDataProvider: DbTreeDataProvider;
@@ -23,7 +26,14 @@ describe('db panel', async () => {
let dbPanel: DbPanel;
before(async () => {
dbConfigStore = new DbConfigStore(workspaceStoragePath, extensionPath);
const extensionContext = createMockExtensionContext({
extensionPath,
globalStoragePath,
workspaceStoragePath
});
const app = new ExtensionApp(extensionContext);
dbConfigStore = new DbConfigStore(app);
dbManager = new DbManager(dbConfigStore);
// Create a modified version of the DbPanel module that allows
@@ -230,7 +240,7 @@ describe('db panel', async () => {
): void {
expect(item.label).to.equal(`Top ${n} repositories`);
expect(item.tooltip).to.equal(`Top ${n} repositories of a language`);
expect(item.icon).to.deep.equal(new vscode.ThemeIcon('github'));
expect(item.iconPath).to.deep.equal(new vscode.ThemeIcon('github'));
expect(item.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.None);
}
@@ -241,7 +251,7 @@ describe('db panel', async () => {
): void {
expect(item.label).to.equal(listName);
expect(item.tooltip).to.be.undefined;
expect(item.icon).to.be.undefined;
expect(item.iconPath).to.be.undefined;
expect(item.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.Collapsed);
expect(item.children).to.be.ok;
expect(item.children.length).to.equal(repos.length);
@@ -257,7 +267,7 @@ describe('db panel', async () => {
): void {
expect(item.label).to.equal(ownerName);
expect(item.tooltip).to.be.undefined;
expect(item.icon).to.deep.equal(new vscode.ThemeIcon('organization'));
expect(item.iconPath).to.deep.equal(new vscode.ThemeIcon('organization'));
expect(item.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.None);
expect(item.children).to.be.ok;
expect(item.children.length).to.equal(0);
@@ -269,7 +279,7 @@ describe('db panel', async () => {
): void {
expect(item.label).to.equal(repoName);
expect(item.tooltip).to.be.undefined;
expect(item.icon).to.deep.equal(new vscode.ThemeIcon('database'));
expect(item.iconPath).to.deep.equal(new vscode.ThemeIcon('database'));
expect(item.collapsibleState).to.equal(vscode.TreeItemCollapsibleState.None);
}
});

View File

@@ -0,0 +1,36 @@
import { App } from '../../src/common/app';
import { AppEvent, AppEventEmitter } from '../../src/common/events';
import { Disposable } from '../../src/pure/disposable-object';
export function createMockApp({
extensionPath = '/mock/extension/path',
workspaceStoragePath = '/mock/workspace/storage/path',
globalStoragePath = '/mock/global/storage/path',
createEventEmitter = <T>() => new MockAppEventEmitter<T>()
}: {
extensionPath?: string,
workspaceStoragePath?: string,
globalStoragePath?: string,
createEventEmitter?: <T>() => AppEventEmitter<T>
}): App {
return {
createEventEmitter,
extensionPath,
workspaceStoragePath,
globalStoragePath
};
}
export class MockAppEventEmitter<T> implements AppEventEmitter<T> {
public event: AppEvent<T>;
constructor() {
this.event = () => {
return {} as Disposable;
};
}
public fire(): void {
// no-op
}
}

View File

@@ -2,6 +2,7 @@ import * as fs from 'fs-extra';
import * as path from 'path';
import { DbConfigStore } from '../../../src/databases/db-config-store';
import { expect } from 'chai';
import { createMockApp } from '../../__mocks__/appMock';
describe('db config store', async () => {
const extensionPath = path.join(__dirname, '../../..');
@@ -17,9 +18,14 @@ describe('db config store', async () => {
});
it('should create a new config if one does not exist', async () => {
const app = createMockApp({
extensionPath,
workspaceStoragePath: tempWorkspaceStoragePath
});
const configPath = path.join(tempWorkspaceStoragePath, 'workspace-databases.json');
const configStore = new DbConfigStore(tempWorkspaceStoragePath, extensionPath);
const configStore = new DbConfigStore(app);
await configStore.initialize();
expect(await fs.pathExists(configPath)).to.be.true;
@@ -32,7 +38,11 @@ describe('db config store', async () => {
});
it('should load an existing config', async () => {
const configStore = new DbConfigStore(testDataStoragePath, extensionPath);
const app = createMockApp({
extensionPath,
workspaceStoragePath: testDataStoragePath
});
const configStore = new DbConfigStore(app);
await configStore.initialize();
const config = configStore.getConfig().value;
@@ -70,7 +80,11 @@ describe('db config store', async () => {
});
it('should not allow modification of the config', async () => {
const configStore = new DbConfigStore(testDataStoragePath, extensionPath);
const app = createMockApp({
extensionPath,
workspaceStoragePath: testDataStoragePath
});
const configStore = new DbConfigStore(app);
await configStore.initialize();
const config = configStore.getConfig().value;

View File

@@ -4,6 +4,7 @@
"exclude": [],
"compilerOptions": {
"noEmit": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"rootDirs": [".", "../src"]
}
}