]> source.dussan.org Git - sonarqube.git/blob
ba696076c9d6483dce5e3598a778845355894db8
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2024 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 { Button, ButtonVariety, Spinner } from '@sonarsource/echoes-react';
22 import classNames from 'classnames';
23 import { FlagMessage, IssueMessageHighlighting, LineFinding, themeColor } from 'design-system';
24 import * as React from 'react';
25 import { FormattedMessage } from 'react-intl';
26 import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
27 import { getSources } from '../../../api/components';
28 import { AvailableFeaturesContext } from '../../../app/components/available-features/AvailableFeaturesContext';
29 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
30 import { TabKeys } from '../../../components/rules/IssueTabViewer';
31 import { TabSelectorContext } from '../../../components/rules/TabSelectorContext';
32 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
33 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
34 import { translate } from '../../../helpers/l10n';
35 import {
36   usePrefetchSuggestion,
37   useUnifiedSuggestionsQuery,
38 } from '../../../queries/fix-suggestions';
39 import { BranchLike } from '../../../types/branch-like';
40 import { isFile } from '../../../types/component';
41 import { Feature } from '../../../types/features';
42 import { IssueDeprecatedStatus } from '../../../types/issues';
43 import {
44   Dict,
45   Duplication,
46   ExpandDirection,
47   FlowLocation,
48   Issue,
49   IssuesByLine,
50   Snippet,
51   SnippetGroup,
52   SourceLine,
53   SourceViewerFile,
54   Issue as TypeIssue,
55 } from '../../../types/types';
56 import { CurrentUser, isLoggedIn } from '../../../types/users';
57 import { IssueSourceViewerScrollContext } from '../components/IssueSourceViewerScrollContext';
58 import { IssueSourceViewerHeader } from './IssueSourceViewerHeader';
59 import SnippetViewer from './SnippetViewer';
60 import {
61   EXPAND_BY_LINES,
62   MERGE_DISTANCE,
63   createSnippets,
64   expandSnippet,
65   getPrimaryLocation,
66   linesForSnippets,
67 } from './utils';
68
69 interface Props {
70   branchLike: BranchLike | undefined;
71   currentUser: CurrentUser;
72   duplications?: Duplication[];
73   duplicationsByLine?: { [line: number]: number[] };
74   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
75   isLastOccurenceOfPrimaryComponent: boolean;
76   issue: TypeIssue;
77   issuesByLine: IssuesByLine;
78   loadDuplications: (component: string, line: SourceLine) => void;
79   locations: FlowLocation[];
80   onIssueSelect: (issueKey: string) => void;
81   onLocationSelect: (index: number) => void;
82   renderDuplicationPopup: (
83     component: SourceViewerFile,
84     index: number,
85     line: number,
86   ) => React.ReactNode;
87   snippetGroup: SnippetGroup;
88 }
89
90 interface State {
91   additionalLines: { [line: number]: SourceLine };
92   highlightedSymbols: string[];
93   loading: boolean;
94   snippets: Snippet[];
95 }
96
97 class ComponentSourceSnippetGroupViewer extends React.PureComponent<Readonly<Props>, State> {
98   mounted = false;
99
100   constructor(props: Readonly<Props>) {
101     super(props);
102     this.state = {
103       additionalLines: {},
104       highlightedSymbols: [],
105       loading: false,
106       snippets: [],
107     };
108   }
109
110   componentDidMount() {
111     this.mounted = true;
112     this.createSnippetsFromProps();
113   }
114
115   componentWillUnmount() {
116     this.mounted = false;
117   }
118
119   createSnippetsFromProps() {
120     const { issue, snippetGroup } = this.props;
121
122     const locations = [...snippetGroup.locations];
123
124     // Add primary location if the component matches
125     if (issue.component === snippetGroup.component.key) {
126       locations.unshift(getPrimaryLocation(issue));
127     }
128
129     const snippets = createSnippets({
130       component: snippetGroup.component.key,
131       issue,
132       locations,
133     });
134
135     this.setState({ snippets });
136   }
137
138   expandBlock = (snippetIndex: number, direction: ExpandDirection): Promise<void> => {
139     const { branchLike, snippetGroup } = this.props;
140     const { key } = snippetGroup.component;
141     const { snippets } = this.state;
142     const snippet = snippets.find((s) => s.index === snippetIndex);
143     if (!snippet) {
144       return Promise.reject();
145     }
146     // Extend by EXPAND_BY_LINES and add buffer for merging snippets
147     const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
148     const range =
149       direction === 'up'
150         ? {
151             from: Math.max(1, snippet.start - extension),
152             to: snippet.start - 1,
153           }
154         : {
155             from: snippet.end + 1,
156             to: snippet.end + extension,
157           };
158     return getSources({
159       key,
160       ...range,
161       ...getBranchLikeQuery(branchLike),
162     })
163       .then((lines) =>
164         lines.reduce((lineMap: Dict<SourceLine>, line) => {
165           line.coverageStatus = getCoverageStatus(line);
166           lineMap[line.line] = line;
167           return lineMap;
168         }, {}),
169       )
170       .then((newLinesMapped) => {
171         const newSnippets = expandSnippet({
172           direction,
173           snippetIndex,
174           snippets,
175         });
176
177         this.setState(({ additionalLines }) => {
178           const combinedLines = { ...additionalLines, ...newLinesMapped };
179           return {
180             additionalLines: combinedLines,
181             snippets: newSnippets.filter((s) => !s.toDelete),
182           };
183         });
184       });
185   };
186
187   expandComponent = () => {
188     const { branchLike, snippetGroup } = this.props;
189     const { key } = snippetGroup.component;
190
191     this.setState({ loading: true });
192
193     getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
194       (lines) => {
195         if (this.mounted) {
196           this.setState(({ additionalLines }) => {
197             const combinedLines = { ...additionalLines, ...lines };
198             return {
199               additionalLines: combinedLines,
200               loading: false,
201               snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }],
202             };
203           });
204         }
205       },
206       () => {
207         if (this.mounted) {
208           this.setState({ loading: false });
209         }
210       },
211     );
212   };
213
214   handleSymbolClick = (clickedSymbols: string[]) => {
215     this.setState(({ highlightedSymbols }) => {
216       const newHighlightedSymbols = clickedSymbols.filter(
217         (symb) => !highlightedSymbols.includes(symb),
218       );
219       return { highlightedSymbols: newHighlightedSymbols };
220     });
221   };
222
223   loadDuplications = (line: SourceLine) => {
224     this.props.loadDuplications(this.props.snippetGroup.component.key, line);
225   };
226
227   renderDuplicationPopup = (index: number, line: number) => {
228     return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
229   };
230
231   renderIssuesList = (line: SourceLine) => {
232     const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup, currentUser } =
233       this.props;
234     const locations =
235       issue.component === snippetGroup.component.key && issue.textRange !== undefined
236         ? locationsByLine([issue])
237         : {};
238
239     const isFlow = issue.secondaryLocations.length === 0;
240     const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
241     const issueLocations = includeIssueLocation ? locations[line.line] : [];
242     const issuesForLine = (issuesByLine[line.line] || []).filter(
243       (issueForline) =>
244         issue.key !== issueForline.key ||
245         (issue.key === issueForline.key && issueLocations.length > 0),
246     );
247
248     return (
249       issuesForLine.length > 0 && (
250         <div>
251           {issuesForLine.map((issueToDisplay) => {
252             const isSelectedIssue = issueToDisplay.key === issue.key;
253             return (
254               <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
255                 {(ctx) => (
256                   <LineFinding
257                     as={isSelectedIssue ? 'div' : undefined}
258                     className="sw-justify-between"
259                     issueKey={issueToDisplay.key}
260                     message={
261                       <IssueMessageHighlighting
262                         message={issueToDisplay.message}
263                         messageFormattings={issueToDisplay.messageFormattings}
264                       />
265                     }
266                     selected={isSelectedIssue}
267                     ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
268                     onIssueSelect={this.props.onIssueSelect}
269                     getFixButton={
270                       isSelectedIssue ? (
271                         <GetFixButton issue={issueToDisplay} currentUser={currentUser} />
272                       ) : undefined
273                     }
274                   />
275                 )}
276               </IssueSourceViewerScrollContext.Consumer>
277             );
278           })}
279         </div>
280       )
281     );
282   };
283
284   render() {
285     const { isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props;
286     const { additionalLines, loading, snippets } = this.state;
287
288     const snippetLines = linesForSnippets(snippets, {
289       ...snippetGroup.sources,
290       ...additionalLines,
291     });
292
293     const issueIsClosed = issue.status === IssueDeprecatedStatus.Closed;
294     const issueIsFileLevel = isFile(issue.componentQualifier) && issue.componentEnabled;
295     const closedIssueMessageKey = issueIsFileLevel
296       ? 'issue.closed.file_level'
297       : 'issue.closed.project_level';
298
299     const hideLocationIndex = issue.secondaryLocations.length !== 0;
300
301     return (
302       <>
303         {issueIsClosed && (
304           <FlagMessage className="sw-mb-2 sw-flex" variant="success">
305             <div className="sw-block">
306               <FormattedMessage
307                 id={closedIssueMessageKey}
308                 defaultMessage={translate(closedIssueMessageKey)}
309                 values={{
310                   status: (
311                     <strong>
312                       {translate('issue.status', issue.status)} (
313                       {issue.resolution ? translate('issue.resolution', issue.resolution) : '-'})
314                     </strong>
315                   ),
316                 }}
317               />
318             </div>
319           </FlagMessage>
320         )}
321
322         <IssueSourceViewerHeader
323           className={issueIsClosed && !issueIsFileLevel ? 'sw-mb-0' : ''}
324           expandable={isExpandable(snippets, snippetGroup)}
325           issueKey={issue.key}
326           loading={loading}
327           onExpand={this.expandComponent}
328           sourceViewerFile={snippetGroup.component}
329         />
330
331         {issue.component === snippetGroup.component.key &&
332           issue.textRange === undefined &&
333           !issueIsClosed && (
334             <FileLevelIssueStyle className="sw-py-2">
335               <IssueSourceViewerScrollContext.Consumer>
336                 {(ctx) => (
337                   <LineFinding
338                     issueKey={issue.key}
339                     message={
340                       <IssueMessageHighlighting
341                         message={issue.message}
342                         messageFormattings={issue.messageFormattings}
343                       />
344                     }
345                     selected
346                     ref={ctx?.registerPrimaryLocationRef}
347                     onIssueSelect={this.props.onIssueSelect}
348                     className="sw-m-0 sw-cursor-default"
349                   />
350                 )}
351               </IssueSourceViewerScrollContext.Consumer>
352             </FileLevelIssueStyle>
353           )}
354
355         {snippetLines.map(({ snippet, sourcesMap }, index) => (
356           <SnippetViewer
357             key={snippets[index].index}
358             renderAdditionalChildInLine={this.renderIssuesList}
359             component={this.props.snippetGroup.component}
360             duplications={this.props.duplications}
361             duplicationsByLine={this.props.duplicationsByLine}
362             expandBlock={this.expandBlock}
363             handleSymbolClick={this.handleSymbolClick}
364             highlightedLocationMessage={this.props.highlightedLocationMessage}
365             highlightedSymbols={this.state.highlightedSymbols}
366             index={snippets[index].index}
367             loadDuplications={this.loadDuplications}
368             locations={this.props.locations}
369             locationsByLine={getLocationsByLine(
370               issue,
371               snippetGroup,
372               isLastOccurenceOfPrimaryComponent,
373             )}
374             onLocationSelect={this.props.onLocationSelect}
375             renderDuplicationPopup={this.renderDuplicationPopup}
376             snippet={snippet}
377             className={classNames({ 'sw-mt-2': index !== 0 })}
378             snippetSourcesMap={sourcesMap}
379             hideLocationIndex={hideLocationIndex}
380           />
381         ))}
382       </>
383     );
384   }
385 }
386
387 function getLocationsByLine(
388   issue: TypeIssue,
389   snippetGroup: SnippetGroup,
390   isLastOccurenceOfPrimaryComponent: boolean,
391 ) {
392   const isFlow = issue.secondaryLocations.length === 0;
393   const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
394
395   return includeIssueLocation &&
396     issue.component === snippetGroup.component.key &&
397     issue.textRange !== undefined
398     ? locationsByLine([issue])
399     : {};
400 }
401
402 function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) {
403   const fullyShown =
404     snippets.length === 1 &&
405     snippetGroup.component.measures &&
406     snippets[0].end - snippets[0].start ===
407       parseInt(snippetGroup.component.measures.lines ?? '', 10);
408
409   return !fullyShown && isFile(snippetGroup.component.q);
410 }
411
412 const FileLevelIssueStyle = styled.div`
413   border: 1px solid ${themeColor('codeLineBorder')};
414 `;
415
416 function GetFixButton({
417   currentUser,
418   issue,
419 }: Readonly<{ currentUser: CurrentUser; issue: Issue }>) {
420   const handler = React.useContext(TabSelectorContext);
421   const { data: suggestion, isLoading } = useUnifiedSuggestionsQuery(issue, false);
422   const prefetchSuggestion = usePrefetchSuggestion(issue.key);
423
424   const isSuggestionFeatureEnabled = React.useContext(AvailableFeaturesContext).includes(
425     Feature.FixSuggestions,
426   );
427
428   if (!isLoggedIn(currentUser) || !isSuggestionFeatureEnabled) {
429     return null;
430   }
431   return (
432     <Spinner ariaLabel={translate('issues.code_fix.fix_is_being_generated')} isLoading={isLoading}>
433       {suggestion !== undefined && (
434         <Button
435           className="sw-shrink-0"
436           onClick={() => {
437             handler(TabKeys.CodeFix);
438           }}
439         >
440           {translate('issues.code_fix.see_fix_suggestion')}
441         </Button>
442       )}
443       {suggestion === undefined && (
444         <Button
445           className="sw-ml-2 sw-shrink-0"
446           onClick={() => {
447             handler(TabKeys.CodeFix);
448             prefetchSuggestion();
449           }}
450           variety={ButtonVariety.Primary}
451         >
452           {translate('issues.code_fix.get_fix_suggestion')}
453         </Button>
454       )}
455     </Spinner>
456   );
457 }
458
459 export default withCurrentUserContext(ComponentSourceSnippetGroupViewer);