]> source.dussan.org Git - sonarqube.git/blob
47d82f53de20ab51c6ab449e51bb62f3eaa22460
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2023 SonarSource SA
4  * mailto:info AT sonarsource DOT com
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU Lesser General Public
8  * License as published by the Free Software Foundation; either
9  * version 3 of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14  * Lesser General Public License for more details.
15  *
16  * You should have received a copy of the GNU Lesser General Public License
17  * along with this program; if not, write to the Free Software Foundation,
18  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19  */
20 import styled from '@emotion/styled';
21 import classNames from 'classnames';
22 import { FlagMessage, IssueMessageHighlighting, LineFinding, themeColor } from 'design-system';
23 import * as React from 'react';
24 import { FormattedMessage } from 'react-intl';
25 import { getSources } from '../../../api/components';
26 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
27 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
28 import { getBranchLikeQuery } from '../../../helpers/branch-like';
29 import { translate } from '../../../helpers/l10n';
30 import { BranchLike } from '../../../types/branch-like';
31 import { isFile } from '../../../types/component';
32 import { IssueStatus } from '../../../types/issues';
33 import {
34   Dict,
35   Duplication,
36   ExpandDirection,
37   FlowLocation,
38   IssuesByLine,
39   Snippet,
40   SnippetGroup,
41   SourceLine,
42   SourceViewerFile,
43   Issue as TypeIssue,
44 } from '../../../types/types';
45 import { IssueSourceViewerScrollContext } from '../components/IssueSourceViewerScrollContext';
46 import IssueSourceViewerHeader from './IssueSourceViewerHeader';
47 import SnippetViewer from './SnippetViewer';
48 import {
49   EXPAND_BY_LINES,
50   MERGE_DISTANCE,
51   createSnippets,
52   expandSnippet,
53   getPrimaryLocation,
54   linesForSnippets,
55 } from './utils';
56
57 interface Props {
58   branchLike: BranchLike | undefined;
59   duplications?: Duplication[];
60   duplicationsByLine?: { [line: number]: number[] };
61   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
62   isLastOccurenceOfPrimaryComponent: boolean;
63   issue: TypeIssue;
64   issuesByLine: IssuesByLine;
65   loadDuplications: (component: string, line: SourceLine) => void;
66   locations: FlowLocation[];
67   onIssueSelect: (issueKey: string) => void;
68   onLocationSelect: (index: number) => void;
69   renderDuplicationPopup: (
70     component: SourceViewerFile,
71     index: number,
72     line: number
73   ) => React.ReactNode;
74   snippetGroup: SnippetGroup;
75 }
76
77 interface State {
78   additionalLines: { [line: number]: SourceLine };
79   highlightedSymbols: string[];
80   loading: boolean;
81   snippets: Snippet[];
82 }
83
84 export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<Props, State> {
85   mounted = false;
86
87   constructor(props: Props) {
88     super(props);
89     this.state = {
90       additionalLines: {},
91       highlightedSymbols: [],
92       loading: false,
93       snippets: [],
94     };
95   }
96
97   componentDidMount() {
98     this.mounted = true;
99     this.createSnippetsFromProps();
100   }
101
102   componentWillUnmount() {
103     this.mounted = false;
104   }
105
106   createSnippetsFromProps() {
107     const { issue, snippetGroup } = this.props;
108
109     const locations = [...snippetGroup.locations];
110
111     // Add primary location if the component matches
112     if (issue.component === snippetGroup.component.key) {
113       locations.unshift(getPrimaryLocation(issue));
114     }
115
116     const snippets = createSnippets({
117       component: snippetGroup.component.key,
118       issue,
119       locations,
120     });
121
122     this.setState({ snippets });
123   }
124
125   expandBlock = (snippetIndex: number, direction: ExpandDirection): Promise<void> => {
126     const { branchLike, snippetGroup } = this.props;
127     const { key } = snippetGroup.component;
128     const { snippets } = this.state;
129     const snippet = snippets.find((s) => s.index === snippetIndex);
130     if (!snippet) {
131       return Promise.reject();
132     }
133     // Extend by EXPAND_BY_LINES and add buffer for merging snippets
134     const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
135     const range =
136       direction === 'up'
137         ? {
138             from: Math.max(1, snippet.start - extension),
139             to: snippet.start - 1,
140           }
141         : {
142             from: snippet.end + 1,
143             to: snippet.end + extension,
144           };
145     return getSources({
146       key,
147       ...range,
148       ...getBranchLikeQuery(branchLike),
149     })
150       .then((lines) =>
151         lines.reduce((lineMap: Dict<SourceLine>, line) => {
152           line.coverageStatus = getCoverageStatus(line);
153           lineMap[line.line] = line;
154           return lineMap;
155         }, {})
156       )
157       .then((newLinesMapped) => {
158         const newSnippets = expandSnippet({
159           direction,
160           snippetIndex,
161           snippets,
162         });
163
164         this.setState(({ additionalLines }) => {
165           const combinedLines = { ...additionalLines, ...newLinesMapped };
166           return {
167             additionalLines: combinedLines,
168             snippets: newSnippets.filter((s) => !s.toDelete),
169           };
170         });
171       });
172   };
173
174   expandComponent = () => {
175     const { branchLike, snippetGroup } = this.props;
176     const { key } = snippetGroup.component;
177
178     this.setState({ loading: true });
179
180     getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
181       (lines) => {
182         if (this.mounted) {
183           this.setState(({ additionalLines }) => {
184             const combinedLines = { ...additionalLines, ...lines };
185             return {
186               additionalLines: combinedLines,
187               loading: false,
188               snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }],
189             };
190           });
191         }
192       },
193       () => {
194         if (this.mounted) {
195           this.setState({ loading: false });
196         }
197       }
198     );
199   };
200
201   handleSymbolClick = (clickedSymbols: string[]) => {
202     this.setState(({ highlightedSymbols }) => {
203       const newHighlightedSymbols = clickedSymbols.filter(
204         (symb) => !highlightedSymbols.includes(symb)
205       );
206       return { highlightedSymbols: newHighlightedSymbols };
207     });
208   };
209
210   loadDuplications = (line: SourceLine) => {
211     this.props.loadDuplications(this.props.snippetGroup.component.key, line);
212   };
213
214   renderDuplicationPopup = (index: number, line: number) => {
215     return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
216   };
217
218   renderIssuesList = (line: SourceLine) => {
219     const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
220     const locations =
221       issue.component === snippetGroup.component.key && issue.textRange !== undefined
222         ? locationsByLine([issue])
223         : {};
224
225     const isFlow = issue.secondaryLocations.length === 0;
226     const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
227     const issueLocations = includeIssueLocation ? locations[line.line] : [];
228     const issuesForLine = (issuesByLine[line.line] || []).filter(
229       (issueForline) =>
230         issue.key !== issueForline.key ||
231         (issue.key === issueForline.key && issueLocations.length > 0)
232     );
233
234     return (
235       issuesForLine.length > 0 && (
236         <div>
237           {issuesForLine.map((issueToDisplay) => {
238             const isSelectedIssue = issueToDisplay.key === issue.key;
239             return (
240               <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
241                 {(ctx) => (
242                   <LineFinding
243                     issueType={issueToDisplay.type}
244                     issueKey={issueToDisplay.key}
245                     message={
246                       <IssueMessageHighlighting
247                         message={issueToDisplay.message}
248                         messageFormattings={issueToDisplay.messageFormattings}
249                       />
250                     }
251                     selected={isSelectedIssue}
252                     ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
253                     onIssueSelect={this.props.onIssueSelect}
254                   />
255                 )}
256               </IssueSourceViewerScrollContext.Consumer>
257             );
258           })}
259         </div>
260       )
261     );
262   };
263
264   render() {
265     const { branchLike, isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props;
266     const { additionalLines, loading, snippets } = this.state;
267
268     const snippetLines = linesForSnippets(snippets, {
269       ...snippetGroup.sources,
270       ...additionalLines,
271     });
272
273     const issueIsClosed = issue.status === IssueStatus.Closed;
274     const issueIsFileLevel = isFile(issue.componentQualifier) && issue.componentEnabled;
275     const closedIssueMessageKey = issueIsFileLevel
276       ? 'issue.closed.file_level'
277       : 'issue.closed.project_level';
278
279     const hideLocationIndex = issue.secondaryLocations.length !== 0;
280
281     return (
282       <>
283         {issueIsClosed && (
284           <FlagMessage className="sw-mb-2 sw-flex" variant="success">
285             <div className="sw-block">
286               <FormattedMessage
287                 id={closedIssueMessageKey}
288                 defaultMessage={translate(closedIssueMessageKey)}
289                 values={{
290                   status: (
291                     <strong>
292                       {translate('issue.status', issue.status)} (
293                       {issue.resolution ? translate('issue.resolution', issue.resolution) : '-'})
294                     </strong>
295                   ),
296                 }}
297               />
298             </div>
299           </FlagMessage>
300         )}
301
302         <IssueSourceViewerHeader
303           branchLike={branchLike}
304           className={issueIsClosed && !issueIsFileLevel ? 'null-spacer-bottom' : ''}
305           expandable={isExpandable(snippets, snippetGroup)}
306           loading={loading}
307           onExpand={this.expandComponent}
308           sourceViewerFile={snippetGroup.component}
309         />
310
311         {issue.component === snippetGroup.component.key &&
312           issue.textRange === undefined &&
313           !issueIsClosed && (
314             <FileLevelIssueStyle className="sw-py-2">
315               <IssueSourceViewerScrollContext.Consumer>
316                 {(ctx) => (
317                   <LineFinding
318                     issueType={issue.type}
319                     issueKey={issue.key}
320                     message={
321                       <IssueMessageHighlighting
322                         message={issue.message}
323                         messageFormattings={issue.messageFormattings}
324                       />
325                     }
326                     selected
327                     ref={ctx?.registerPrimaryLocationRef}
328                     onIssueSelect={this.props.onIssueSelect}
329                     className="sw-m-0 sw-cursor-default"
330                   />
331                 )}
332               </IssueSourceViewerScrollContext.Consumer>
333             </FileLevelIssueStyle>
334           )}
335
336         {snippetLines.map(({ snippet, sourcesMap }, index) => (
337           <SnippetViewer
338             key={snippets[index].index}
339             renderAdditionalChildInLine={this.renderIssuesList}
340             component={this.props.snippetGroup.component}
341             duplications={this.props.duplications}
342             duplicationsByLine={this.props.duplicationsByLine}
343             expandBlock={this.expandBlock}
344             handleSymbolClick={this.handleSymbolClick}
345             highlightedLocationMessage={this.props.highlightedLocationMessage}
346             highlightedSymbols={this.state.highlightedSymbols}
347             index={snippets[index].index}
348             loadDuplications={this.loadDuplications}
349             locations={this.props.locations}
350             locationsByLine={getLocationsByLine(
351               issue,
352               snippetGroup,
353               isLastOccurenceOfPrimaryComponent
354             )}
355             onLocationSelect={this.props.onLocationSelect}
356             renderDuplicationPopup={this.renderDuplicationPopup}
357             snippet={snippet}
358             className={classNames({ 'sw-mt-2': index !== 0 })}
359             snippetSourcesMap={sourcesMap}
360             hideLocationIndex={hideLocationIndex}
361           />
362         ))}
363       </>
364     );
365   }
366 }
367
368 function getLocationsByLine(
369   issue: TypeIssue,
370   snippetGroup: SnippetGroup,
371   isLastOccurenceOfPrimaryComponent: boolean
372 ) {
373   const isFlow = issue.secondaryLocations.length === 0;
374   const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
375
376   return includeIssueLocation &&
377     issue.component === snippetGroup.component.key &&
378     issue.textRange !== undefined
379     ? locationsByLine([issue])
380     : {};
381 }
382
383 function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) {
384   const fullyShown =
385     snippets.length === 1 &&
386     snippetGroup.component.measures &&
387     snippets[0].end - snippets[0].start ===
388       parseInt(snippetGroup.component.measures.lines || '', 10);
389
390   return !fullyShown && isFile(snippetGroup.component.q);
391 }
392
393 const FileLevelIssueStyle = styled.div`
394   border: 1px solid ${themeColor('codeLineBorder')};
395 `;