diff --git a/extensions/ql-vscode/src/stories/remote-queries/RepositoriesSearch.stories.tsx b/extensions/ql-vscode/src/stories/remote-queries/RepositoriesSearch.stories.tsx index 4ef198a62..749a4a4f0 100644 --- a/extensions/ql-vscode/src/stories/remote-queries/RepositoriesSearch.stories.tsx +++ b/extensions/ql-vscode/src/stories/remote-queries/RepositoriesSearch.stories.tsx @@ -5,7 +5,7 @@ import { ComponentMeta } from '@storybook/react'; import RepositoriesSearchComponent from '../../view/remote-queries/RepositoriesSearch'; export default { - title: 'Repositories Search', + title: 'MRVA/Repositories Search', component: RepositoriesSearchComponent, argTypes: { filterValue: { diff --git a/extensions/ql-vscode/src/stories/variant-analysis/RepositoriesSearch.stories.tsx b/extensions/ql-vscode/src/stories/variant-analysis/RepositoriesSearch.stories.tsx new file mode 100644 index 000000000..e8f78e50f --- /dev/null +++ b/extensions/ql-vscode/src/stories/variant-analysis/RepositoriesSearch.stories.tsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react'; + +import { ComponentMeta } from '@storybook/react'; + +import { RepositoriesSearch as RepositoriesSearchComponent } from '../../view/variant-analysis/RepositoriesSearch'; + +export default { + title: 'Variant Analysis/Repositories Search', + component: RepositoriesSearchComponent, + argTypes: { + value: { + control: { + disable: true, + }, + }, + } +} as ComponentMeta; + +export const RepositoriesSearch = () => { + const [value, setValue] = useState(''); + + return ( + + ); +}; diff --git a/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisAnalyzedRepos.stories.tsx b/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisAnalyzedRepos.stories.tsx index 0a3ec8abc..967f82505 100644 --- a/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisAnalyzedRepos.stories.tsx +++ b/extensions/ql-vscode/src/stories/variant-analysis/VariantAnalysisAnalyzedRepos.stories.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { faker } from '@faker-js/faker'; + import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer'; import { VariantAnalysisAnalyzedRepos } from '../../view/variant-analysis/VariantAnalysisAnalyzedRepos'; import { @@ -11,6 +13,7 @@ import { import { AnalysisAlert } from '../../remote-queries/shared/analysis-result'; import { createMockVariantAnalysis } from '../../vscode-tests/factories/remote-queries/shared/variant-analysis'; import { createMockRepositoryWithMetadata } from '../../vscode-tests/factories/remote-queries/shared/repository'; +import { createMockScannedRepo } from '../../vscode-tests/factories/remote-queries/shared/scanned-repositories'; import analysesResults from '../remote-queries/data/analysesResultsMessage.json'; @@ -111,5 +114,40 @@ Example.args = { interpretedResults: interpretedResultsForRepo('expressjs/express'), } ] -} - ; +}; + +faker.seed(42); +const uniqueStore = {}; + +const manyScannedRepos = Array.from({ length: 1000 }, (_, i) => { + const mockedScannedRepo = createMockScannedRepo(); + + return { + ...mockedScannedRepo, + analysisStatus: VariantAnalysisRepoStatus.Succeeded, + resultCount: faker.datatype.number({ min: 0, max: 1000 }), + repository: { + ...mockedScannedRepo.repository, + // We need to ensure the ID is unique for React keys + id: faker.helpers.unique(faker.datatype.number, [], { + store: uniqueStore, + }), + fullName: `octodemo/${faker.helpers.unique(faker.random.word, [], { + store: uniqueStore, + })}`, + } + }; +}); + +export const PerformanceExample = Template.bind({}); +PerformanceExample.args = { + variantAnalysis: { + ...createMockVariantAnalysis(VariantAnalysisStatus.Succeeded, manyScannedRepos), + id: 1, + }, + repositoryResults: manyScannedRepos.map(repoTask => ({ + variantAnalysisId: 1, + repositoryId: repoTask.repository.id, + interpretedResults: interpretedResultsForRepo('facebook/create-react-app'), + })) +}; diff --git a/extensions/ql-vscode/src/view/common/icon/Codicon.tsx b/extensions/ql-vscode/src/view/common/icon/Codicon.tsx index 2017b7b44..e60124ff8 100644 --- a/extensions/ql-vscode/src/view/common/icon/Codicon.tsx +++ b/extensions/ql-vscode/src/view/common/icon/Codicon.tsx @@ -6,6 +6,7 @@ type Props = { name: string; label: string; className?: string; + slot?: string; }; const CodiconIcon = styled.span` @@ -15,5 +16,6 @@ const CodiconIcon = styled.span` export const Codicon = ({ name, label, - className -}: Props) => ; + className, + slot, +}: Props) => ; diff --git a/extensions/ql-vscode/src/view/variant-analysis/RepositoriesSearch.tsx b/extensions/ql-vscode/src/view/variant-analysis/RepositoriesSearch.tsx new file mode 100644 index 000000000..fdb74f5fc --- /dev/null +++ b/extensions/ql-vscode/src/view/variant-analysis/RepositoriesSearch.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { useCallback } from 'react'; +import styled from 'styled-components'; +import { VSCodeTextField } from '@vscode/webview-ui-toolkit/react'; +import { Codicon } from '../common'; + +const TextField = styled(VSCodeTextField)` + width: 100%; +`; + +type Props = { + value: string; + onChange: (value: string) => void; +} + +export const RepositoriesSearch = ({ value, onChange }: Props) => { + const handleInput = useCallback((e: InputEvent) => { + const target = e.target as HTMLInputElement; + + onChange(target.value); + }, [onChange]); + + return ( + + + + ); +}; diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisAnalyzedRepos.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisAnalyzedRepos.tsx index 05eaedaa5..59eb63aa9 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisAnalyzedRepos.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisAnalyzedRepos.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useMemo } from 'react'; import styled from 'styled-components'; import { RepoRow } from './RepoRow'; import { @@ -6,7 +7,7 @@ import { VariantAnalysisScannedRepositoryResult, VariantAnalysisScannedRepositoryState } from '../../remote-queries/shared/variant-analysis'; -import { useMemo } from 'react'; +import { matchesSearchValue } from './filterSort'; const Container = styled.div` display: flex; @@ -19,12 +20,15 @@ export type VariantAnalysisAnalyzedReposProps = { variantAnalysis: VariantAnalysis; repositoryStates?: VariantAnalysisScannedRepositoryState[]; repositoryResults?: VariantAnalysisScannedRepositoryResult[]; + + searchValue?: string; } export const VariantAnalysisAnalyzedRepos = ({ variantAnalysis, repositoryStates, repositoryResults, + searchValue, }: VariantAnalysisAnalyzedReposProps) => { const repositoryStateById = useMemo(() => { const map = new Map(); @@ -42,9 +46,19 @@ export const VariantAnalysisAnalyzedRepos = ({ return map; }, [repositoryResults]); + const repositories = useMemo(() => { + if (searchValue) { + return variantAnalysis.scannedRepos?.filter((repoTask) => { + return matchesSearchValue(repoTask.repository, searchValue); + }); + } + + return variantAnalysis.scannedRepos; + }, [searchValue, variantAnalysis.scannedRepos]); + return ( - {variantAnalysis.scannedRepos?.map(repository => { + {repositories?.map(repository => { const state = repositoryStateById.get(repository.repository.id); const results = repositoryResultsById.get(repository.repository.id); diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisOutcomePanels.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisOutcomePanels.tsx index f5d592397..581f42a0b 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisOutcomePanels.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisOutcomePanels.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useState } from 'react'; import styled from 'styled-components'; import { VSCodeBadge, VSCodePanels, VSCodePanelTab, VSCodePanelView } from '@vscode/webview-ui-toolkit/react'; import { formatDecimal } from '../../pure/number'; @@ -10,6 +11,7 @@ import { import { VariantAnalysisAnalyzedRepos } from './VariantAnalysisAnalyzedRepos'; import { Alert } from '../common'; import { VariantAnalysisSkippedRepositoriesTab } from './VariantAnalysisSkippedRepositoriesTab'; +import { RepositoriesSearch } from './RepositoriesSearch'; export type VariantAnalysisOutcomePanelProps = { variantAnalysis: VariantAnalysis; @@ -42,6 +44,8 @@ export const VariantAnalysisOutcomePanels = ({ repositoryStates, repositoryResults, }: VariantAnalysisOutcomePanelProps) => { + const [searchValue, setSearchValue] = useState(''); + const noCodeqlDbRepos = variantAnalysis.skippedRepos?.noCodeqlDbRepos; const notFoundRepos = variantAnalysis.skippedRepos?.notFoundRepos; const overLimitRepositoryCount = variantAnalysis.skippedRepos?.overLimitRepos?.repositoryCount ?? 0; @@ -70,10 +74,12 @@ export const VariantAnalysisOutcomePanels = ({ return ( <> {warnings} + ); @@ -82,6 +88,7 @@ export const VariantAnalysisOutcomePanels = ({ return ( <> {warnings} + Analyzed @@ -104,6 +111,7 @@ export const VariantAnalysisOutcomePanels = ({ variantAnalysis={variantAnalysis} repositoryStates={repositoryStates} repositoryResults={repositoryResults} + searchValue={searchValue} /> {notFoundRepos?.repositoryCount && @@ -111,14 +119,18 @@ export const VariantAnalysisOutcomePanels = ({ + skippedRepositoryGroup={notFoundRepos} + searchValue={searchValue} + /> } {noCodeqlDbRepos?.repositoryCount && + skippedRepositoryGroup={noCodeqlDbRepos} + searchValue={searchValue} + /> } diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisSkippedRepositoriesTab.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisSkippedRepositoriesTab.tsx index 26b9029f9..d0ea1dc6b 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisSkippedRepositoriesTab.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysisSkippedRepositoriesTab.tsx @@ -1,13 +1,17 @@ import * as React from 'react'; +import { useMemo } from 'react'; import styled from 'styled-components'; import { VariantAnalysisSkippedRepositoryGroup } from '../../remote-queries/shared/variant-analysis'; import { Alert } from '../common'; import { RepoRow } from './RepoRow'; +import { matchesSearchValue } from './filterSort'; export type VariantAnalysisSkippedRepositoriesTabProps = { alertTitle: string, alertMessage: string, skippedRepositoryGroup: VariantAnalysisSkippedRepositoryGroup, + + searchValue?: string, }; function getSkipReasonAlert( @@ -39,11 +43,22 @@ export const VariantAnalysisSkippedRepositoriesTab = ({ alertTitle, alertMessage, skippedRepositoryGroup, + searchValue, }: VariantAnalysisSkippedRepositoriesTabProps) => { + const repositories = useMemo(() => { + if (searchValue) { + return skippedRepositoryGroup.repositories?.filter((repo) => { + return matchesSearchValue(repo, searchValue); + }); + } + + return skippedRepositoryGroup.repositories; + }, [searchValue, skippedRepositoryGroup.repositories]); + return ( {getSkipReasonAlert(alertTitle, alertMessage, skippedRepositoryGroup)} - {skippedRepositoryGroup.repositories.map((repo) => + {repositories.map((repo) => )} diff --git a/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisAnalyzedRepos.spec.tsx b/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisAnalyzedRepos.spec.tsx index 0c2d87ee7..399ce0cba 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisAnalyzedRepos.spec.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisAnalyzedRepos.spec.tsx @@ -107,4 +107,15 @@ describe(VariantAnalysisAnalyzedRepos.name, () => { })); expect(screen.getByText('This is an empty block.')).toBeInTheDocument(); }); + + it('uses the search value', () => { + render({ + searchValue: 'world-2', + }); + + expect(screen.queryByText('octodemo/hello-world-1')).not.toBeInTheDocument(); + expect(screen.getByText('octodemo/hello-world-2')).toBeInTheDocument(); + expect(screen.queryByText('octodemo/hello-world-3')).not.toBeInTheDocument(); + expect(screen.queryByText('octodemo/hello-world-4')).not.toBeInTheDocument(); + }); }); diff --git a/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisSkippedRepositoriesTab.spec.tsx b/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisSkippedRepositoriesTab.spec.tsx index 38558db51..9e781d78f 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisSkippedRepositoriesTab.spec.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/__tests__/VariantAnalysisSkippedRepositoriesTab.spec.tsx @@ -97,4 +97,30 @@ describe(VariantAnalysisSkippedRepositoriesTab.name, () => { expect(screen.getByText('octodemo/hello-galaxy')).toBeInTheDocument(); expect(screen.getByText('octodemo/hello-universe')).toBeInTheDocument(); }); + + it('uses the search value', async () => { + render({ + alertTitle: 'No database', + alertMessage: 'The following repositories could not be scanned because they do not have an available CodeQL database.', + skippedRepositoryGroup: { + repositoryCount: 1, + repositories: [ + { + fullName: 'octodemo/hello-world', + }, + { + fullName: 'octodemo/hello-galaxy', + }, + { + fullName: 'octodemo/hello-universe', + }, + ], + }, + searchValue: 'world', + }); + + expect(screen.getByText('octodemo/hello-world')).toBeInTheDocument(); + expect(screen.queryByText('octodemo/hello-galaxy')).not.toBeInTheDocument(); + expect(screen.queryByText('octodemo/hello-universe')).not.toBeInTheDocument(); + }); }); diff --git a/extensions/ql-vscode/src/view/variant-analysis/__tests__/filterSort.spec.ts b/extensions/ql-vscode/src/view/variant-analysis/__tests__/filterSort.spec.ts new file mode 100644 index 000000000..6beb0df4f --- /dev/null +++ b/extensions/ql-vscode/src/view/variant-analysis/__tests__/filterSort.spec.ts @@ -0,0 +1,27 @@ +import { matchesSearchValue } from '../filterSort'; + +describe(matchesSearchValue.name, () => { + const repository = { + fullName: 'github/codeql' + }; + + const testCases = [ + { searchValue: undefined, matches: true }, + { searchValue: '', matches: true }, + { searchValue: 'github/codeql', matches: true }, + { searchValue: 'github', matches: true }, + { searchValue: 'git', matches: true }, + { searchValue: 'codeql', matches: true }, + { searchValue: 'code', matches: true }, + { searchValue: 'ql', matches: true }, + { searchValue: '/', matches: true }, + { searchValue: 'gothub/codeql', matches: false }, + { searchValue: 'hello', matches: false }, + { searchValue: 'cod*ql', matches: false }, + { searchValue: 'cod?ql', matches: false }, + ]; + + test.each(testCases)('returns $matches if searching for $searchValue', ({ searchValue, matches }) => { + expect(matchesSearchValue(repository, searchValue)).toBe(matches); + }); +}); diff --git a/extensions/ql-vscode/src/view/variant-analysis/filterSort.ts b/extensions/ql-vscode/src/view/variant-analysis/filterSort.ts new file mode 100644 index 000000000..ee57cc1e0 --- /dev/null +++ b/extensions/ql-vscode/src/view/variant-analysis/filterSort.ts @@ -0,0 +1,9 @@ +import { Repository } from '../../remote-queries/shared/repository'; + +export function matchesSearchValue(repo: Pick, searchValue: string | undefined): boolean { + if (!searchValue) { + return true; + } + + return repo.fullName.toLowerCase().includes(searchValue.toLowerCase()); +}