3 * Copyright (C) 2009-2024 SonarSource SA
4 * mailto:info AT sonarsource DOT com
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.
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.
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.
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';
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';
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';
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;
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,
87 snippetGroup: SnippetGroup;
91 additionalLines: { [line: number]: SourceLine };
92 highlightedSymbols: string[];
97 class ComponentSourceSnippetGroupViewer extends React.PureComponent<Readonly<Props>, State> {
100 constructor(props: Readonly<Props>) {
104 highlightedSymbols: [],
110 componentDidMount() {
112 this.createSnippetsFromProps();
115 componentWillUnmount() {
116 this.mounted = false;
119 createSnippetsFromProps() {
120 const { issue, snippetGroup } = this.props;
122 const locations = [...snippetGroup.locations];
124 // Add primary location if the component matches
125 if (issue.component === snippetGroup.component.key) {
126 locations.unshift(getPrimaryLocation(issue));
129 const snippets = createSnippets({
130 component: snippetGroup.component.key,
135 this.setState({ snippets });
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);
144 return Promise.reject();
146 // Extend by EXPAND_BY_LINES and add buffer for merging snippets
147 const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
151 from: Math.max(1, snippet.start - extension),
152 to: snippet.start - 1,
155 from: snippet.end + 1,
156 to: snippet.end + extension,
161 ...getBranchLikeQuery(branchLike),
164 lines.reduce((lineMap: Dict<SourceLine>, line) => {
165 line.coverageStatus = getCoverageStatus(line);
166 lineMap[line.line] = line;
170 .then((newLinesMapped) => {
171 const newSnippets = expandSnippet({
177 this.setState(({ additionalLines }) => {
178 const combinedLines = { ...additionalLines, ...newLinesMapped };
180 additionalLines: combinedLines,
181 snippets: newSnippets.filter((s) => !s.toDelete),
187 expandComponent = () => {
188 const { branchLike, snippetGroup } = this.props;
189 const { key } = snippetGroup.component;
191 this.setState({ loading: true });
193 getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
196 this.setState(({ additionalLines }) => {
197 const combinedLines = { ...additionalLines, ...lines };
199 additionalLines: combinedLines,
201 snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }],
208 this.setState({ loading: false });
214 handleSymbolClick = (clickedSymbols: string[]) => {
215 this.setState(({ highlightedSymbols }) => {
216 const newHighlightedSymbols = clickedSymbols.filter(
217 (symb) => !highlightedSymbols.includes(symb),
219 return { highlightedSymbols: newHighlightedSymbols };
223 loadDuplications = (line: SourceLine) => {
224 this.props.loadDuplications(this.props.snippetGroup.component.key, line);
227 renderDuplicationPopup = (index: number, line: number) => {
228 return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
231 renderIssuesList = (line: SourceLine) => {
232 const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup, currentUser } =
235 issue.component === snippetGroup.component.key && issue.textRange !== undefined
236 ? locationsByLine([issue])
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(
244 issue.key !== issueForline.key ||
245 (issue.key === issueForline.key && issueLocations.length > 0),
249 issuesForLine.length > 0 && (
251 {issuesForLine.map((issueToDisplay) => {
252 const isSelectedIssue = issueToDisplay.key === issue.key;
254 <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
257 as={isSelectedIssue ? 'div' : undefined}
258 className="sw-justify-between"
259 issueKey={issueToDisplay.key}
261 <IssueMessageHighlighting
262 message={issueToDisplay.message}
263 messageFormattings={issueToDisplay.messageFormattings}
266 selected={isSelectedIssue}
267 ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
268 onIssueSelect={this.props.onIssueSelect}
271 <GetFixButton issue={issueToDisplay} currentUser={currentUser} />
276 </IssueSourceViewerScrollContext.Consumer>
285 const { isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props;
286 const { additionalLines, loading, snippets } = this.state;
288 const snippetLines = linesForSnippets(snippets, {
289 ...snippetGroup.sources,
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';
299 const hideLocationIndex = issue.secondaryLocations.length !== 0;
304 <FlagMessage className="sw-mb-2 sw-flex" variant="success">
305 <div className="sw-block">
307 id={closedIssueMessageKey}
308 defaultMessage={translate(closedIssueMessageKey)}
312 {translate('issue.status', issue.status)} (
313 {issue.resolution ? translate('issue.resolution', issue.resolution) : '-'})
322 <IssueSourceViewerHeader
323 className={issueIsClosed && !issueIsFileLevel ? 'sw-mb-0' : ''}
324 expandable={isExpandable(snippets, snippetGroup)}
327 onExpand={this.expandComponent}
328 sourceViewerFile={snippetGroup.component}
331 {issue.component === snippetGroup.component.key &&
332 issue.textRange === undefined &&
334 <FileLevelIssueStyle className="sw-py-2">
335 <IssueSourceViewerScrollContext.Consumer>
340 <IssueMessageHighlighting
341 message={issue.message}
342 messageFormattings={issue.messageFormattings}
346 ref={ctx?.registerPrimaryLocationRef}
347 onIssueSelect={this.props.onIssueSelect}
348 className="sw-m-0 sw-cursor-default"
351 </IssueSourceViewerScrollContext.Consumer>
352 </FileLevelIssueStyle>
355 {snippetLines.map(({ snippet, sourcesMap }, index) => (
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(
372 isLastOccurenceOfPrimaryComponent,
374 onLocationSelect={this.props.onLocationSelect}
375 renderDuplicationPopup={this.renderDuplicationPopup}
377 className={classNames({ 'sw-mt-2': index !== 0 })}
378 snippetSourcesMap={sourcesMap}
379 hideLocationIndex={hideLocationIndex}
387 function getLocationsByLine(
389 snippetGroup: SnippetGroup,
390 isLastOccurenceOfPrimaryComponent: boolean,
392 const isFlow = issue.secondaryLocations.length === 0;
393 const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
395 return includeIssueLocation &&
396 issue.component === snippetGroup.component.key &&
397 issue.textRange !== undefined
398 ? locationsByLine([issue])
402 function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) {
404 snippets.length === 1 &&
405 snippetGroup.component.measures &&
406 snippets[0].end - snippets[0].start ===
407 parseInt(snippetGroup.component.measures.lines ?? '', 10);
409 return !fullyShown && isFile(snippetGroup.component.q);
412 const FileLevelIssueStyle = styled.div`
413 border: 1px solid ${themeColor('codeLineBorder')};
416 function GetFixButton({
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);
424 const isSuggestionFeatureEnabled = React.useContext(AvailableFeaturesContext).includes(
425 Feature.FixSuggestions,
428 if (!isLoggedIn(currentUser) || !isSuggestionFeatureEnabled) {
432 <Spinner ariaLabel={translate('issues.code_fix.fix_is_being_generated')} isLoading={isLoading}>
433 {suggestion !== undefined && (
435 className="sw-shrink-0"
437 handler(TabKeys.CodeFix);
440 {translate('issues.code_fix.see_fix_suggestion')}
443 {suggestion === undefined && (
445 className="sw-ml-2 sw-shrink-0"
447 handler(TabKeys.CodeFix);
448 prefetchSuggestion();
450 variety={ButtonVariety.Primary}
452 {translate('issues.code_fix.get_fix_suggestion')}
459 export default withCurrentUserContext(ComponentSourceSnippetGroupViewer);