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 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 { IssueDeprecatedStatus } 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<
90 constructor(props: Readonly<Props>) {
94 highlightedSymbols: [],
100 componentDidMount() {
102 this.createSnippetsFromProps();
105 componentWillUnmount() {
106 this.mounted = false;
109 createSnippetsFromProps() {
110 const { issue, snippetGroup } = this.props;
112 const locations = [...snippetGroup.locations];
114 // Add primary location if the component matches
115 if (issue.component === snippetGroup.component.key) {
116 locations.unshift(getPrimaryLocation(issue));
119 const snippets = createSnippets({
120 component: snippetGroup.component.key,
125 this.setState({ snippets });
128 expandBlock = (snippetIndex: number, direction: ExpandDirection): Promise<void> => {
129 const { branchLike, snippetGroup } = this.props;
130 const { key } = snippetGroup.component;
131 const { snippets } = this.state;
132 const snippet = snippets.find((s) => s.index === snippetIndex);
134 return Promise.reject();
136 // Extend by EXPAND_BY_LINES and add buffer for merging snippets
137 const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
141 from: Math.max(1, snippet.start - extension),
142 to: snippet.start - 1,
145 from: snippet.end + 1,
146 to: snippet.end + extension,
151 ...getBranchLikeQuery(branchLike),
154 lines.reduce((lineMap: Dict<SourceLine>, line) => {
155 line.coverageStatus = getCoverageStatus(line);
156 lineMap[line.line] = line;
160 .then((newLinesMapped) => {
161 const newSnippets = expandSnippet({
167 this.setState(({ additionalLines }) => {
168 const combinedLines = { ...additionalLines, ...newLinesMapped };
170 additionalLines: combinedLines,
171 snippets: newSnippets.filter((s) => !s.toDelete),
177 expandComponent = () => {
178 const { branchLike, snippetGroup } = this.props;
179 const { key } = snippetGroup.component;
181 this.setState({ loading: true });
183 getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
186 this.setState(({ additionalLines }) => {
187 const combinedLines = { ...additionalLines, ...lines };
189 additionalLines: combinedLines,
191 snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }],
198 this.setState({ loading: false });
204 handleSymbolClick = (clickedSymbols: string[]) => {
205 this.setState(({ highlightedSymbols }) => {
206 const newHighlightedSymbols = clickedSymbols.filter(
207 (symb) => !highlightedSymbols.includes(symb),
209 return { highlightedSymbols: newHighlightedSymbols };
213 loadDuplications = (line: SourceLine) => {
214 this.props.loadDuplications(this.props.snippetGroup.component.key, line);
217 renderDuplicationPopup = (index: number, line: number) => {
218 return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
221 renderIssuesList = (line: SourceLine) => {
222 const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
224 issue.component === snippetGroup.component.key && issue.textRange !== undefined
225 ? locationsByLine([issue])
228 const isFlow = issue.secondaryLocations.length === 0;
229 const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
230 const issueLocations = includeIssueLocation ? locations[line.line] : [];
231 const issuesForLine = (issuesByLine[line.line] || []).filter(
233 issue.key !== issueForline.key ||
234 (issue.key === issueForline.key && issueLocations.length > 0),
238 issuesForLine.length > 0 && (
240 {issuesForLine.map((issueToDisplay) => {
241 const isSelectedIssue = issueToDisplay.key === issue.key;
243 <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
246 issueKey={issueToDisplay.key}
248 <IssueMessageHighlighting
249 message={issueToDisplay.message}
250 messageFormattings={issueToDisplay.messageFormattings}
253 selected={isSelectedIssue}
254 ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
255 onIssueSelect={this.props.onIssueSelect}
258 </IssueSourceViewerScrollContext.Consumer>
267 const { isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props;
268 const { additionalLines, loading, snippets } = this.state;
270 const snippetLines = linesForSnippets(snippets, {
271 ...snippetGroup.sources,
275 const issueIsClosed = issue.status === IssueDeprecatedStatus.Closed;
276 const issueIsFileLevel = isFile(issue.componentQualifier) && issue.componentEnabled;
277 const closedIssueMessageKey = issueIsFileLevel
278 ? 'issue.closed.file_level'
279 : 'issue.closed.project_level';
281 const hideLocationIndex = issue.secondaryLocations.length !== 0;
286 <FlagMessage className="sw-mb-2 sw-flex" variant="success">
287 <div className="sw-block">
289 id={closedIssueMessageKey}
290 defaultMessage={translate(closedIssueMessageKey)}
294 {translate('issue.status', issue.status)} (
295 {issue.resolution ? translate('issue.resolution', issue.resolution) : '-'})
304 <IssueSourceViewerHeader
305 className={issueIsClosed && !issueIsFileLevel ? 'null-spacer-bottom' : ''}
306 expandable={isExpandable(snippets, snippetGroup)}
309 onExpand={this.expandComponent}
310 sourceViewerFile={snippetGroup.component}
313 {issue.component === snippetGroup.component.key &&
314 issue.textRange === undefined &&
316 <FileLevelIssueStyle className="sw-py-2">
317 <IssueSourceViewerScrollContext.Consumer>
322 <IssueMessageHighlighting
323 message={issue.message}
324 messageFormattings={issue.messageFormattings}
328 ref={ctx?.registerPrimaryLocationRef}
329 onIssueSelect={this.props.onIssueSelect}
330 className="sw-m-0 sw-cursor-default"
333 </IssueSourceViewerScrollContext.Consumer>
334 </FileLevelIssueStyle>
337 {snippetLines.map(({ snippet, sourcesMap }, index) => (
339 key={snippets[index].index}
340 renderAdditionalChildInLine={this.renderIssuesList}
341 component={this.props.snippetGroup.component}
342 duplications={this.props.duplications}
343 duplicationsByLine={this.props.duplicationsByLine}
344 expandBlock={this.expandBlock}
345 handleSymbolClick={this.handleSymbolClick}
346 highlightedLocationMessage={this.props.highlightedLocationMessage}
347 highlightedSymbols={this.state.highlightedSymbols}
348 index={snippets[index].index}
349 loadDuplications={this.loadDuplications}
350 locations={this.props.locations}
351 locationsByLine={getLocationsByLine(
354 isLastOccurenceOfPrimaryComponent,
356 onLocationSelect={this.props.onLocationSelect}
357 renderDuplicationPopup={this.renderDuplicationPopup}
359 className={classNames({ 'sw-mt-2': index !== 0 })}
360 snippetSourcesMap={sourcesMap}
361 hideLocationIndex={hideLocationIndex}
369 function getLocationsByLine(
371 snippetGroup: SnippetGroup,
372 isLastOccurenceOfPrimaryComponent: boolean,
374 const isFlow = issue.secondaryLocations.length === 0;
375 const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
377 return includeIssueLocation &&
378 issue.component === snippetGroup.component.key &&
379 issue.textRange !== undefined
380 ? locationsByLine([issue])
384 function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) {
386 snippets.length === 1 &&
387 snippetGroup.component.measures &&
388 snippets[0].end - snippets[0].start ===
389 parseInt(snippetGroup.component.measures.lines ?? '', 10);
391 return !fullyShown && isFile(snippetGroup.component.q);
394 const FileLevelIssueStyle = styled.div`
395 border: 1px solid ${themeColor('codeLineBorder')};