From fadf1df93389c41e8ccd836541161d688c6742ca Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Thu, 8 Jun 2023 10:21:56 +0200 Subject: [PATCH] SONAR-19489 Add coverage/new code highlights/labels to the code viewer --- .../crossComponentSourceViewer/utils.ts | 15 +- .../SourceViewer/SourceViewerCode.tsx | 148 +++++++++++++----- .../__tests__/SourceViewer-it.tsx | 2 +- .../SourceViewer/components/Line.tsx | 10 +- .../src/main/js/helpers/code-viewer.ts | 42 +++++ 5 files changed, 158 insertions(+), 59 deletions(-) create mode 100644 server/sonar-web/src/main/js/helpers/code-viewer.ts diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts index 92f3c466f86..4b2eda9d13d 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts @@ -17,7 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { sortBy } from 'lodash'; +import { decorateWithUnderlineFlags } from '../../../helpers/code-viewer'; import { isDefined } from '../../../helpers/types'; import { ComponentQualifier } from '../../../types/component'; import { ReviewHistoryElement, ReviewHistoryType } from '../../../types/security-hotspots'; @@ -138,19 +140,6 @@ export function createSnippets(params: { return hasSecondaryLocations ? ranges.sort((a, b) => a.start - b.start) : ranges; } -function decorateWithUnderlineFlags(line: SourceLine, sourcesMap: LineMap) { - const previousLine = sourcesMap[line.line - 1]; - const decoratedLine = { ...line }; - if (isDefined(line.coverageStatus)) { - decoratedLine.coverageBlock = - line.coverageStatus === previousLine?.coverageStatus ? previousLine.coverageBlock : line.line; - } - if (line.isNew) { - decoratedLine.newCodeBlock = previousLine?.isNew ? previousLine.newCodeBlock : line.line; - } - return decoratedLine; -} - export function linesForSnippets(snippets: Snippet[], componentLines: LineMap) { return snippets.reduce>((acc, snippet) => { const snippetSources = []; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx index 9988a9f1903..95ccad809f2 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx @@ -17,10 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { SonarCodeColorizer } from 'design-system/lib'; -import { noop } from 'lodash'; + +import { SonarCodeColorizer } from 'design-system'; import * as React from 'react'; import { Button } from '../../components/controls/buttons'; +import { decorateWithUnderlineFlags } from '../../helpers/code-viewer'; import { translate } from '../../helpers/l10n'; import { BranchLike } from '../../types/branch-like'; import { MetricKey } from '../../types/metrics'; @@ -28,6 +29,7 @@ import { Duplication, FlowLocation, Issue, + LineMap, LinearIssueLocation, SourceLine, } from '../../types/types'; @@ -36,7 +38,7 @@ import LineIssuesList from './components/LineIssuesList'; import { getSecondaryIssueLocationsForLine } from './helpers/issueLocations'; import { optimizeHighlightedSymbols, optimizeLocationMessage } from './helpers/lines'; -const EMPTY_ARRAY: any[] = []; +const EMPTY_ARRAY: unknown[] = []; const ZERO_LINE = { code: '', @@ -45,6 +47,11 @@ const ZERO_LINE = { line: 0, }; +interface State { + decoratedLinesMap: LineMap; + hoveredLine?: SourceLine; +} + interface Props { branchLike: BranchLike | undefined; displayAllIssues?: boolean; @@ -68,6 +75,7 @@ interface Props { loadingSourcesBefore: boolean; loadSourcesAfter: () => void; loadSourcesBefore: () => void; + metricKey?: string; onIssueChange: (issue: Issue) => void; onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void; onIssuesClose: (line: SourceLine) => void; @@ -78,21 +86,42 @@ interface Props { onSymbolClick: (symbols: string[]) => void; openIssuesByLine: { [line: number]: boolean }; renderDuplicationPopup: (index: number, line: number) => React.ReactNode; - metricKey?: string; selectedIssue: string | undefined; sources: SourceLine[]; symbolsByLine: { [line: number]: string[] }; } -export default class SourceViewerCode extends React.PureComponent { +export default class SourceViewerCode extends React.PureComponent { firstUncoveredLineFound = false; + constructor(props: Props) { + super(props); + + this.state = { + decoratedLinesMap: this.getDecoratedLinesMap(props.sources), + hoveredLine: undefined, + }; + } + componentDidUpdate(prevProps: Props) { if (this.props.metricKey !== prevProps.metricKey) { this.firstUncoveredLineFound = false; } + + if (this.props.sources !== prevProps.sources) { + this.setState({ + decoratedLinesMap: this.getDecoratedLinesMap(this.props.sources), + }); + } } + getDecoratedLinesMap = (sources: SourceLine[]) => + sources.reduce((map: LineMap, line: SourceLine) => { + map[line.line] = decorateWithUnderlineFlags(line, map); + + return map; + }, {}); + getDuplicationsForLine = (line: SourceLine): number[] => { return this.props.duplicationsByLine[line.line] || EMPTY_ARRAY; }; @@ -105,38 +134,61 @@ export default class SourceViewerCode extends React.PureComponent { return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY; }; + onLineMouseEnter = (hoveredLineNumber: number) => + this.setState(({ decoratedLinesMap }) => ({ + hoveredLine: decoratedLinesMap[hoveredLineNumber], + })); + + onLineMouseLeave = (leftLineNumber: number) => + this.setState(({ hoveredLine }) => ({ + hoveredLine: hoveredLine?.line === leftLineNumber ? undefined : hoveredLine, + })); + renderLine = ({ - line, - index, displayCoverage, displayDuplications, displayIssues, + index, + line, }: { - line: SourceLine; - index: number; displayCoverage: boolean; displayDuplications: boolean; displayIssues: boolean; + index: number; + line: SourceLine; }) => { + const { hoveredLine } = this.state; + const { - highlightedLocationMessage, - selectedIssue, - openIssuesByLine, - issueLocationsByLine, + branchLike, displayAllIssues, + displayIssueLocationsCount, + displayIssueLocationsLink, + displayLocationMarkers, + duplications, + highlightedLine, + highlightedLocationMessage, highlightedLocations, + highlightedSymbols, + issueLocationsByLine, + issuePopup, metricKey, + openIssuesByLine, + selectedIssue, sources, + symbolsByLine, } = this.props; const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, highlightedLocations); - const duplicationsCount = this.props.duplications ? this.props.duplications.length : 0; + const duplicationsCount = duplications?.length ?? 0; const issuesForLine = this.getIssuesForLine(line); - const firstLineNumber = sources && sources.length ? sources[0].line : 0; + + const firstLineNumber = sources?.length ? sources[0].line : 0; let scrollToUncoveredLine = false; + if ( !this.firstUncoveredLineFound && displayCoverage && @@ -146,62 +198,69 @@ export default class SourceViewerCode extends React.PureComponent { scrollToUncoveredLine = (metricKey === MetricKey.new_uncovered_lines && line.isNew) || metricKey === MetricKey.uncovered_lines; + this.firstUncoveredLineFound = scrollToUncoveredLine; } + const displayCoverageUnderline = !!( + hoveredLine?.coverageBlock && hoveredLine.coverageBlock === line.coverageBlock + ); + return ( 0} duplications={this.getDuplicationsForLine(line)} duplicationsCount={duplicationsCount} firstLineNumber={firstLineNumber} - highlighted={line.line === this.props.highlightedLine} + highlighted={line.line === highlightedLine} highlightedLocationMessage={optimizeLocationMessage( highlightedLocationMessage, secondaryIssueLocations )} highlightedSymbols={optimizeHighlightedSymbols( - this.props.symbolsByLine[line.line], - this.props.highlightedSymbols + symbolsByLine[line.line], + highlightedSymbols )} issueLocations={this.getIssueLocationsForLine(line)} issues={issuesForLine} key={line.line || line.code} line={line} loadDuplications={this.props.loadDuplications} - onIssueSelect={this.props.onIssueSelect} - onIssueUnselect={this.props.onIssueUnselect} onIssuesClose={this.props.onIssuesClose} + onIssueSelect={this.props.onIssueSelect} onIssuesOpen={this.props.onIssuesOpen} + onIssueUnselect={this.props.onIssueUnselect} + onLineMouseEnter={this.onLineMouseEnter} + onLineMouseLeave={this.onLineMouseLeave} onLocationSelect={this.props.onLocationSelect} onSymbolClick={this.props.onSymbolClick} - openIssues={this.props.openIssuesByLine[line.line] || false} + openIssues={openIssuesByLine[line.line] || false} previousLine={index > 0 ? sources[index - 1] : undefined} renderDuplicationPopup={this.props.renderDuplicationPopup} scrollToUncoveredLine={scrollToUncoveredLine} secondaryIssueLocations={secondaryIssueLocations} > @@ -209,7 +268,16 @@ export default class SourceViewerCode extends React.PureComponent { }; render() { - const { issues = [], sources } = this.props; + const { decoratedLinesMap } = this.state; + + const { + hasSourcesAfter, + hasSourcesBefore, + issues = [], + loadingSourcesAfter, + loadingSourcesBefore, + sources, + } = this.props; const displayCoverage = sources.some((s) => s.coverageStatus != null); const displayDuplications = sources.some((s) => !!s.duplicated); @@ -220,9 +288,9 @@ export default class SourceViewerCode extends React.PureComponent { return (
- {this.props.hasSourcesBefore && ( + {hasSourcesBefore && (
- {this.props.loadingSourcesBefore ? ( + {loadingSourcesBefore ? (
@@ -244,27 +312,27 @@ export default class SourceViewerCode extends React.PureComponent { {hasFileIssues && this.renderLine({ - line: ZERO_LINE, - index: -1, displayCoverage, displayDuplications, displayIssues, + index: -1, + line: ZERO_LINE, })} {sources.map((line, index) => this.renderLine({ - line, - index, displayCoverage, displayDuplications, displayIssues, + index, + line: decoratedLinesMap[line.line] || line, }) )} - {this.props.hasSourcesAfter && ( + {hasSourcesAfter && (
- {this.props.loadingSourcesAfter ? ( + {loadingSourcesAfter ? (
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx index 731a881f3a2..3f11fccd9d1 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx @@ -172,7 +172,7 @@ it('should be able to interact with issue action', async () => { ).toBeInTheDocument(); }); -it('should load line when looking arround unloaded line', async () => { +it('should load line when looking around unloaded line', async () => { const rerender = renderSourceViewer({ aroundLine: 50, component: componentsHandler.getHugeFileKey(), diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx index bfd4403d196..9a725c5523a 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import classNames from 'classnames'; import { LineCoverage, LineMeta, LineNumber, LineWrapper } from 'design-system'; import { times } from 'lodash'; @@ -75,7 +76,7 @@ export default function Line(props: LineProps) { displayAllIssues, displayCoverage, displayDuplications, - displayLineNumberOptions, + displayLineNumberOptions = true, displayLocationMarkers, highlightedLocationMessage, displayNewCodeUnderline, @@ -125,9 +126,6 @@ export default function Line(props: LineProps) { [line.line, onLineMouseLeave] ); - // default is true - const displayOptions = displayLineNumberOptions !== false; - const { branchLike, file } = useSourceViewerContext(); const permalink = getPathUrlAsString( getCodeUrl(file.project, branchLike, file.key, line.line), @@ -171,7 +169,7 @@ export default function Line(props: LineProps) { className={classNames('it__source-line', { 'it__source-line-filtered': line.isNew })} > {displaySCM && } + {displayIssues && !displayAllIssues ? ( )} +