3 * Copyright (C) 2009-2023 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 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';
44 } from '../../../types/types';
45 import { IssueSourceViewerScrollContext } from '../components/IssueSourceViewerScrollContext';
46 import IssueSourceViewerHeader from './IssueSourceViewerHeader';
47 import SnippetViewer from './SnippetViewer';
58 branchLike: BranchLike | undefined;
59 duplications?: Duplication[];
60 duplicationsByLine?: { [line: number]: number[] };
61 highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
62 isLastOccurenceOfPrimaryComponent: boolean;
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,
74 snippetGroup: SnippetGroup;
78 additionalLines: { [line: number]: SourceLine };
79 highlightedSymbols: string[];
84 export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<Props, State> {
87 constructor(props: Props) {
91 highlightedSymbols: [],
99 this.createSnippetsFromProps();
102 componentWillUnmount() {
103 this.mounted = false;
106 createSnippetsFromProps() {
107 const { issue, snippetGroup } = this.props;
109 const locations = [...snippetGroup.locations];
111 // Add primary location if the component matches
112 if (issue.component === snippetGroup.component.key) {
113 locations.unshift(getPrimaryLocation(issue));
116 const snippets = createSnippets({
117 component: snippetGroup.component.key,
122 this.setState({ snippets });
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);
131 return Promise.reject();
133 // Extend by EXPAND_BY_LINES and add buffer for merging snippets
134 const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
138 from: Math.max(1, snippet.start - extension),
139 to: snippet.start - 1,
142 from: snippet.end + 1,
143 to: snippet.end + extension,
148 ...getBranchLikeQuery(branchLike),
151 lines.reduce((lineMap: Dict<SourceLine>, line) => {
152 line.coverageStatus = getCoverageStatus(line);
153 lineMap[line.line] = line;
157 .then((newLinesMapped) => {
158 const newSnippets = expandSnippet({
164 this.setState(({ additionalLines }) => {
165 const combinedLines = { ...additionalLines, ...newLinesMapped };
167 additionalLines: combinedLines,
168 snippets: newSnippets.filter((s) => !s.toDelete),
174 expandComponent = () => {
175 const { branchLike, snippetGroup } = this.props;
176 const { key } = snippetGroup.component;
178 this.setState({ loading: true });
180 getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
183 this.setState(({ additionalLines }) => {
184 const combinedLines = { ...additionalLines, ...lines };
186 additionalLines: combinedLines,
188 snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }],
195 this.setState({ loading: false });
201 handleSymbolClick = (clickedSymbols: string[]) => {
202 this.setState(({ highlightedSymbols }) => {
203 const newHighlightedSymbols = clickedSymbols.filter(
204 (symb) => !highlightedSymbols.includes(symb)
206 return { highlightedSymbols: newHighlightedSymbols };
210 loadDuplications = (line: SourceLine) => {
211 this.props.loadDuplications(this.props.snippetGroup.component.key, line);
214 renderDuplicationPopup = (index: number, line: number) => {
215 return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
218 renderIssuesList = (line: SourceLine) => {
219 const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
221 issue.component === snippetGroup.component.key && issue.textRange !== undefined
222 ? locationsByLine([issue])
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(
230 issue.key !== issueForline.key ||
231 (issue.key === issueForline.key && issueLocations.length > 0)
235 issuesForLine.length > 0 && (
237 {issuesForLine.map((issueToDisplay) => {
238 const isSelectedIssue = issueToDisplay.key === issue.key;
240 <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
243 issueType={issueToDisplay.type}
244 issueKey={issueToDisplay.key}
246 <IssueMessageHighlighting
247 message={issueToDisplay.message}
248 messageFormattings={issueToDisplay.messageFormattings}
251 selected={isSelectedIssue}
252 ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
253 onIssueSelect={this.props.onIssueSelect}
256 </IssueSourceViewerScrollContext.Consumer>
265 const { branchLike, isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props;
266 const { additionalLines, loading, snippets } = this.state;
268 const snippetLines = linesForSnippets(snippets, {
269 ...snippetGroup.sources,
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';
279 const hideLocationIndex = issue.secondaryLocations.length !== 0;
284 <FlagMessage className="sw-mb-2 sw-flex" variant="success">
285 <div className="sw-block">
287 id={closedIssueMessageKey}
288 defaultMessage={translate(closedIssueMessageKey)}
292 {translate('issue.status', issue.status)} (
293 {issue.resolution ? translate('issue.resolution', issue.resolution) : '-'})
302 <IssueSourceViewerHeader
303 branchLike={branchLike}
304 className={issueIsClosed && !issueIsFileLevel ? 'null-spacer-bottom' : ''}
305 expandable={isExpandable(snippets, snippetGroup)}
307 onExpand={this.expandComponent}
308 sourceViewerFile={snippetGroup.component}
311 {issue.component === snippetGroup.component.key &&
312 issue.textRange === undefined &&
314 <FileLevelIssueStyle className="sw-py-2">
315 <IssueSourceViewerScrollContext.Consumer>
318 issueType={issue.type}
321 <IssueMessageHighlighting
322 message={issue.message}
323 messageFormattings={issue.messageFormattings}
327 ref={ctx?.registerPrimaryLocationRef}
328 onIssueSelect={this.props.onIssueSelect}
329 className="sw-m-0 sw-cursor-default"
332 </IssueSourceViewerScrollContext.Consumer>
333 </FileLevelIssueStyle>
336 {snippetLines.map(({ snippet, sourcesMap }, index) => (
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(
353 isLastOccurenceOfPrimaryComponent
355 onLocationSelect={this.props.onLocationSelect}
356 renderDuplicationPopup={this.renderDuplicationPopup}
358 className={classNames({ 'sw-mt-2': index !== 0 })}
359 snippetSourcesMap={sourcesMap}
360 hideLocationIndex={hideLocationIndex}
368 function getLocationsByLine(
370 snippetGroup: SnippetGroup,
371 isLastOccurenceOfPrimaryComponent: boolean
373 const isFlow = issue.secondaryLocations.length === 0;
374 const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
376 return includeIssueLocation &&
377 issue.component === snippetGroup.component.key &&
378 issue.textRange !== undefined
379 ? locationsByLine([issue])
383 function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) {
385 snippets.length === 1 &&
386 snippetGroup.component.measures &&
387 snippets[0].end - snippets[0].start ===
388 parseInt(snippetGroup.component.measures.lines || '', 10);
390 return !fullyShown && isFile(snippetGroup.component.q);
393 const FileLevelIssueStyle = styled.div`
394 border: 1px solid ${themeColor('codeLineBorder')};