From 6c37e7a9c7ef1b048c2067250235e24b386f4fc9 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 17 Apr 2019 18:31:50 +0200 Subject: [PATCH] SONAR-11898 New codeviewer for multi-location issues (#1466) Also includes SONAR-11901: Add slim header for the issues page --- server/sonar-web/src/main/js/api/issues.ts | 19 + .../app/styles/components/component-name.css | 4 +- .../src/main/js/app/styles/init/misc.css | 4 + server/sonar-web/src/main/js/app/types.d.ts | 19 + .../js/apps/issues/__tests__/actions-test.ts | 36 +- .../src/main/js/apps/issues/actions.ts | 20 +- .../main/js/apps/issues/components/App.tsx | 75 ++-- .../issues/components/IssuesSourceViewer.tsx | 107 ++++-- .../ComponentSourceSnippetViewer.tsx | 361 ++++++++++++++++++ .../CrossComponentSourceViewer.tsx | 26 ++ .../CrossComponentSourceViewerWrapper.tsx | 138 +++++++ .../ComponentSourceSnippetViewer-test.tsx | 98 +++++ ...CrossComponentSourceViewerWrapper-test.tsx | 73 ++++ ...ComponentSourceSnippetViewer-test.tsx.snap | 36 ++ ...ComponentSourceViewerWrapper-test.tsx.snap | 9 + .../__tests__/utils-test.ts | 193 ++++++++++ .../crossComponentSourceViewer/utils.ts | 185 +++++++++ .../src/main/js/apps/issues/styles.css | 39 ++ .../SourceViewer/SourceViewerBase.tsx | 34 +- .../SourceViewer/SourceViewerCode.tsx | 68 +--- .../SourceViewer/SourceViewerHeader.tsx | 2 +- .../SourceViewer/SourceViewerHeaderSlim.css | 30 ++ .../SourceViewer/SourceViewerHeaderSlim.tsx | 92 +++++ .../SourceViewer/components/Line.css | 257 +++++++++++++ .../SourceViewer/components/Line.tsx | 23 +- .../SourceViewer/components/LineCode.tsx | 36 +- .../components/__tests__/Line-test.tsx | 2 - .../__snapshots__/Line-test.tsx.snap | 226 ++++++++--- .../SourceViewer/helpers/indexing.ts | 12 + .../SourceViewer/helpers/issueLocations.tsx | 22 ++ .../components/SourceViewer/helpers/lines.ts | 47 +++ .../js/components/SourceViewer/styles.css | 235 ------------ .../js/components/common/LocationIndex.css | 2 +- .../js/components/common/LocationMessage.css | 5 + .../icons-components/ExpandSnippetIcon.tsx | 48 +++ .../src/main/js/helpers/testMocks.ts | 42 +- .../resources/org/sonar/l10n/core.properties | 2 + 37 files changed, 2139 insertions(+), 488 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts create mode 100644 server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts create mode 100644 server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css create mode 100644 server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx create mode 100644 server/sonar-web/src/main/js/components/SourceViewer/components/Line.css create mode 100644 server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts create mode 100644 server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts index 27c1ccc513c..769512154d4 100644 --- a/server/sonar-web/src/main/js/api/issues.ts +++ b/server/sonar-web/src/main/js/api/issues.ts @@ -20,6 +20,7 @@ import { getJSON, post, postJSON, RequestData } from '../helpers/request'; import { RawIssue } from '../helpers/issues'; import throwGlobalError from '../app/utils/throwGlobalError'; +import getCoverageStatus from '../components/SourceViewer/helpers/getCoverageStatus'; export interface IssueResponse { components?: Array<{ key: string; name: string }>; @@ -166,3 +167,21 @@ export function searchIssueAuthors(data: { }): Promise { return getJSON('/api/issues/authors', data).then(r => r.authors, throwGlobalError); } + +export function getIssueFlowSnippets(issueKey: string): Promise> { + return getJSON('/api/sources/issue_snippets', { issueKey }).then(result => { + Object.keys(result).forEach(k => { + if (result[k].sources) { + result[k].sources = result[k].sources.reduce( + (lineMap: T.Dict, line: T.SourceLine) => { + line.coverageStatus = getCoverageStatus(line); + lineMap[line.line] = line; + return lineMap; + }, + {} + ); + } + }); + return result; + }, throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/app/styles/components/component-name.css b/server/sonar-web/src/main/js/app/styles/components/component-name.css index 30f78dc2cda..bc6c11628ea 100644 --- a/server/sonar-web/src/main/js/app/styles/components/component-name.css +++ b/server/sonar-web/src/main/js/app/styles/components/component-name.css @@ -52,8 +52,6 @@ } .component-name-favorite { - position: relative; - top: -1px; margin-left: 4px; - padding: 2px 0; + padding: 0; } diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index bbe084f4229..bae0eeefa72 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -57,6 +57,10 @@ th.nowrap { font-size: var(--smallFontSize); } +.nudged-up { + margin-top: -1px; +} + .spacer-left { margin-left: 8px !important; } diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts index cc3d2a1c2ff..fe4c3c30419 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -273,6 +273,8 @@ declare namespace T { export type EditionKey = 'community' | 'developer' | 'enterprise' | 'datacenter'; + export type ExpandDirection = 'up' | 'down'; + export interface Extension { key: string; name: string; @@ -286,6 +288,7 @@ declare namespace T { export interface FlowLocation { component: string; componentName?: string; + index?: number; msg?: string; textRange: TextRange; } @@ -400,6 +403,9 @@ declare namespace T { export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT'; + export interface IssuesByLine { + [key: number]: Issue[]; + } export interface Language { key: string; name: string; @@ -418,9 +424,14 @@ declare namespace T { index?: number; line: number; startLine?: number; + text?: string; to: number; } + export interface LineMap { + [line: number]: SourceLine; + } + export interface LoggedInUser extends CurrentUser { avatar?: string; email?: string; @@ -794,6 +805,14 @@ declare namespace T { type: 'SHORT'; } + export interface SnippetGroup extends SnippetsByComponent { + locations: T.FlowLocation[]; + } + export interface SnippetsByComponent { + component: SourceViewerFile; + sources: { [line: number]: SourceLine }; + } + export interface SourceLine { code?: string; conditions?: number; diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts b/server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts index adf9389ca7a..bead280986a 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts @@ -17,12 +17,36 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { selectFlow } from '../actions'; -it('should select flow and enable locations navigator', () => { - expect(selectFlow(5)()).toEqual({ - locationsNavigator: true, - selectedFlowIndex: 5, - selectedLocationIndex: 0 +import { selectFlow, selectLocation } from '../actions'; +import { mockIssue } from '../../../helpers/testMocks'; + +describe('selectFlow', () => { + it('should select flow and enable locations navigator', () => { + expect(selectFlow(5)()).toEqual({ + locationsNavigator: true, + selectedFlowIndex: 5, + selectedLocationIndex: 0 + }); + }); +}); + +describe('selectLocation', () => { + it('should select location and enable locations navigator', () => { + expect(selectLocation(5)({ openIssue: mockIssue() })).toEqual({ + locationsNavigator: true, + selectedLocationIndex: 5 + }); + }); + + it('should deselect location when clicked again', () => { + expect(selectLocation(5)({ openIssue: mockIssue(), selectedLocationIndex: 5 })).toEqual({ + locationsNavigator: false, + selectedLocationIndex: undefined + }); + }); + + it('should ignore if no open issue', () => { + expect(selectLocation(5)({ openIssue: undefined })).toBeNull(); }); }); diff --git a/server/sonar-web/src/main/js/apps/issues/actions.ts b/server/sonar-web/src/main/js/apps/issues/actions.ts index 937aee31746..7a8b79f0179 100644 --- a/server/sonar-web/src/main/js/apps/issues/actions.ts +++ b/server/sonar-web/src/main/js/apps/issues/actions.ts @@ -43,27 +43,27 @@ export function disableLocationsNavigator() { return { locationsNavigator: false }; } -export function selectLocation(nextIndex: number | undefined) { - return (state: State) => { +export function selectLocation(nextIndex: number) { + return (state: Pick) => { const { selectedLocationIndex: index, openIssue } = state; if (openIssue) { - if (!state.locationsNavigator) { - if (nextIndex !== undefined) { - return { locationsNavigator: true, selectedLocationIndex: nextIndex }; - } - } else if (index !== undefined) { + if (index === nextIndex) { // disable locations when selecting (clicking) the same location return { - locationsNavigator: nextIndex !== index, - selectedLocationIndex: nextIndex + locationsNavigator: false, + selectedLocationIndex: undefined }; + } else { + return { locationsNavigator: true, selectedLocationIndex: nextIndex }; } } return null; }; } -export function selectNextLocation(state: State) { +export function selectNextLocation( + state: Pick +) { const { selectedFlowIndex, selectedLocationIndex: index, openIssue } = state; if (openIssue) { const locations = diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.tsx b/server/sonar-web/src/main/js/apps/issues/components/App.tsx index 0b7307f2ce5..d9f9ea8607f 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/App.tsx @@ -23,7 +23,6 @@ import * as key from 'keymaster'; import Helmet from 'react-helmet'; import { keyBy, omit, without } from 'lodash'; import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal'; -import ComponentBreadcrumbs from './ComponentBreadcrumbs'; import IssuesList from './IssuesList'; import IssuesSourceViewer from './IssuesSourceViewer'; import MyIssuesFilter from './MyIssuesFilter'; @@ -802,7 +801,7 @@ export class App extends React.PureComponent { ); }; - selectLocation = (index?: number) => { + selectLocation = (index: number) => { this.setState(actions.selectLocation(index)); }; @@ -1036,13 +1035,49 @@ export class App extends React.PureComponent { ); } + renderHeader({ + openIssue, + paging, + selectedIndex + }: { + openIssue: T.Issue | undefined; + paging: T.Paging | undefined; + selectedIndex: number | undefined; + }) { + return openIssue ? ( + + ) : ( +
+
+
+ + + {this.renderBulkChange(openIssue)} + +
+
+
+ ); + } + renderPage() { - const { checkAll, loading, openIssue, paging } = this.state; + const { checkAll, issues, loading, openIssue, paging } = this.state; return (
{openIssue ? ( { } render() { - const { component } = this.props; const { openIssue, paging } = this.state; const selectedIndex = this.getSelectedIndex(); return ( @@ -1082,38 +1116,7 @@ export class App extends React.PureComponent { {this.renderSide(openIssue)}
-
-
-
- - - {this.renderBulkChange(openIssue)} - {openIssue ? ( -
- -
- ) : ( - - )} -
-
-
+ {this.renderHeader({ openIssue, paging, selectedIndex })} {this.renderPage()}
diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx index 3e2d6ee44c7..2845950c8ac 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx @@ -18,12 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { uniq } from 'lodash'; import { getLocations, getSelectedLocation } from '../utils'; -import SourceViewer from '../../../components/SourceViewer/SourceViewer'; import { scrollToElement } from '../../../helpers/scrolling'; +import CrossComponentSourceViewer from '../crossComponentSourceViewer/CrossComponentSourceViewer'; +import SourceViewer from '../../../components/SourceViewer/SourceViewer'; interface Props { branchLike: T.BranchLike | undefined; + issues: T.Issue[]; loadIssues: (component: string, from: number, to: number) => Promise; locationsNavigator: boolean; onIssueChange: (issue: T.Issue) => void; @@ -72,57 +75,77 @@ export default class IssuesSourceViewer extends React.PureComponent { render() { const { openIssue, selectedFlowIndex, selectedLocationIndex } = this.props; - const locations = getLocations(openIssue, selectedFlowIndex); + const locations = getLocations(openIssue, selectedFlowIndex).map((loc, index) => { + loc.index = index; + return loc; + }); const selectedLocation = getSelectedLocation( openIssue, selectedFlowIndex, selectedLocationIndex ); - const component = selectedLocation ? selectedLocation.component : openIssue.component; - - // if location is selected, show (and load) code around it - // otherwise show code around the open issue - const aroundLine = selectedLocation - ? selectedLocation.textRange.startLine - : openIssue.textRange && openIssue.textRange.endLine; - - // replace locations in another file with `undefined` to keep the same location indexes - const highlightedLocations = locations.map(location => - location.component === component ? location : undefined - ); - const highlightedLocationMessage = this.props.locationsNavigator && selectedLocationIndex !== undefined ? selectedLocation && { index: selectedLocationIndex, text: selectedLocation.msg } : undefined; - const allMessagesEmpty = locations !== undefined && locations.every(location => !location.msg); - - // do not load issues when open another file for a location - const loadIssues = - component === openIssue.component ? this.props.loadIssues : () => Promise.resolve([]); - const selectedIssue = component === openIssue.component ? openIssue.key : undefined; - - return ( -
(this.node = node)}> - -
- ); + if (locations.length > 1) { + const components = uniq(locations.map(l => l.component)); + return ( +
(this.node = node)}> + +
+ ); + } else { + // if location is selected, show (and load) code around it + // otherwise show code around the open issue + const aroundLine = selectedLocation + ? selectedLocation.textRange.startLine + : openIssue.textRange && openIssue.textRange.endLine; + + const component = selectedLocation ? selectedLocation.component : openIssue.component; + + const highlightedLocations = locations.filter(location => location.component === component); + + // do not load issues when open another file for a location + const loadIssues = + component === openIssue.component ? this.props.loadIssues : () => Promise.resolve([]); + const selectedIssue = component === openIssue.component ? openIssue.key : undefined; + + return ( +
(this.node = node)}> + +
+ ); + } } } diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx new file mode 100644 index 00000000000..9e5c90afc8c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx @@ -0,0 +1,361 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { + createSnippets, + expandSnippet, + inSnippet, + EXPAND_BY_LINES, + LINES_BELOW_LAST, + MERGE_DISTANCE +} from './utils'; +import { getSources } from '../../../api/components'; +import ExpandSnippetIcon from '../../../components/icons-components/ExpandSnippetIcon'; +import Line from '../../../components/SourceViewer/components/Line'; +import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim'; +import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus'; +import { symbolsByLine, locationsByLine } from '../../../components/SourceViewer/helpers/indexing'; +import { getSecondaryIssueLocationsForLine } from '../../../components/SourceViewer/helpers/issueLocations'; +import { + optimizeLocationMessage, + optimizeHighlightedSymbols, + optimizeSelectedIssue +} from '../../../components/SourceViewer/helpers/lines'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + branchLike: T.BranchLike | undefined; + highlightedLocationMessage: { index: number; text: string | undefined } | undefined; + issue: T.Issue; + issuePopup?: { issue: string; name: string }; + issuesByLine: T.IssuesByLine; + last: boolean; + locations: T.FlowLocation[]; + onIssueChange: (issue: T.Issue) => void; + onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void; + onLocationSelect: (index: number) => void; + renderDuplicationPopup: (index: number, line: number) => JSX.Element; + scroll?: (element: HTMLElement) => void; + snippetGroup: T.SnippetGroup; +} + +interface State { + additionalLines: { [line: number]: T.SourceLine }; + highlightedSymbols: string[]; + loading: boolean; + openIssuesByLine: T.Dict; + snippets: T.SourceLine[][]; +} + +export default class ComponentSourceSnippetViewer extends React.PureComponent { + mounted = false; + state: State = { + additionalLines: {}, + highlightedSymbols: [], + loading: false, + openIssuesByLine: {}, + snippets: [] + }; + + componentDidMount() { + this.mounted = true; + this.createSnippetsFromProps(); + } + + componentWillUnmount() { + this.mounted = false; + } + + createSnippetsFromProps() { + const mainLocation: T.FlowLocation = { + component: this.props.issue.component, + textRange: this.props.issue.textRange || { + endLine: 0, + endOffset: 0, + startLine: 0, + startOffset: 0 + } + }; + const snippets = createSnippets( + this.props.snippetGroup.locations.concat(mainLocation), + this.props.snippetGroup.sources, + this.props.last + ); + this.setState({ snippets }); + } + + expandBlock = (snippetIndex: number, direction: T.ExpandDirection) => { + const { snippets } = this.state; + + const snippet = snippets[snippetIndex]; + + // Extend by EXPAND_BY_LINES and add buffer for merging snippets + const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1; + + const range = + direction === 'up' + ? { + from: Math.max(1, snippet[0].line - extension), + to: snippet[0].line - 1 + } + : { + from: snippet[snippet.length - 1].line + 1, + to: snippet[snippet.length - 1].line + extension + }; + + getSources({ + key: this.props.snippetGroup.component.key, + ...range + }) + .then(lines => + lines.reduce((lineMap: T.Dict, line) => { + line.coverageStatus = getCoverageStatus(line); + lineMap[line.line] = line; + return lineMap; + }, {}) + ) + .then( + newLinesMapped => { + if (this.mounted) { + this.setState(({ additionalLines, snippets }) => { + const combinedLines = { ...additionalLines, ...newLinesMapped }; + + return { + additionalLines: combinedLines, + snippets: expandSnippet({ + direction, + lines: { ...combinedLines, ...this.props.snippetGroup.sources }, + snippetIndex, + snippets + }) + }; + }); + } + }, + () => null + ); + }; + + expandComponent = () => { + const { key } = this.props.snippetGroup.component; + + this.setState({ loading: true }); + + getSources({ key }).then( + lines => { + if (this.mounted) { + this.setState({ loading: false, snippets: [lines] }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + }; + + handleOpenIssues = (line: T.SourceLine) => { + this.setState(state => ({ + openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true } + })); + }; + + handleCloseIssues = (line: T.SourceLine) => { + this.setState(state => ({ + openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false } + })); + }; + + renderLine({ + index, + issuesForLine, + issueLocations, + line, + snippet, + symbols, + verticalBuffer + }: { + index: number; + issuesForLine: T.Issue[]; + issueLocations: T.LinearIssueLocation[]; + line: T.SourceLine; + snippet: T.SourceLine[]; + symbols: string[]; + verticalBuffer: number; + }) { + const { openIssuesByLine } = this.state; + + const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, this.props.locations); + + const noop = () => {}; + + const isSinkLine = issuesForLine.some(i => i.key === this.props.issue.key); + + return ( + 1} + displayLocationMarkers={true} + duplications={[]} + duplicationsCount={0} + highlighted={false} + highlightedLocationMessage={optimizeLocationMessage( + this.props.highlightedLocationMessage, + secondaryIssueLocations + )} + highlightedSymbols={optimizeHighlightedSymbols(symbols, this.state.highlightedSymbols)} + issueLocations={issueLocations} + issuePopup={this.props.issuePopup} + issues={issuesForLine} + key={line.line} + last={false} + line={line} + linePopup={undefined} + loadDuplications={noop} + onIssueChange={this.props.onIssueChange} + onIssuePopupToggle={this.props.onIssuePopupToggle} + onIssueSelect={noop} + onIssueUnselect={noop} + onIssuesClose={this.handleCloseIssues} + onIssuesOpen={this.handleOpenIssues} + onLinePopupToggle={noop} + onLocationSelect={this.props.onLocationSelect} + onSymbolClick={highlightedSymbols => this.setState({ highlightedSymbols })} + openIssues={openIssuesByLine[line.line]} + previousLine={index > 0 ? snippet[index - 1] : undefined} + renderDuplicationPopup={this.props.renderDuplicationPopup} + scroll={this.props.scroll} + secondaryIssueLocations={secondaryIssueLocations} + selectedIssue={optimizeSelectedIssue(this.props.issue.key, issuesForLine)} + verticalBuffer={verticalBuffer} + /> + ); + } + + renderSnippet({ + snippet, + index, + issue, + issuesByLine = {}, + locationsByLine, + last + }: { + snippet: T.SourceLine[]; + index: number; + issue: T.Issue; + issuesByLine: T.IssuesByLine; + locationsByLine: { [line: number]: T.LinearIssueLocation[] }; + last: boolean; + }) { + const { component } = this.props.snippetGroup; + const lastLine = + component.measures && component.measures.lines && parseInt(component.measures.lines, 10); + + const symbols = symbolsByLine(snippet); + + const expandBlock = (direction: T.ExpandDirection) => () => this.expandBlock(index, direction); + + const bottomLine = snippet[snippet.length - 1].line; + const issueLine = issue.textRange ? issue.textRange.endLine : issue.line; + const lowestVisibleIssue = Math.max( + ...Object.keys(issuesByLine) + .map(k => parseInt(k, 10)) + .filter(l => inSnippet(l, snippet) && (l === issueLine || this.state.openIssuesByLine[l])) + ); + const verticalBuffer = last + ? Math.max(0, LINES_BELOW_LAST - (bottomLine - lowestVisibleIssue)) + : 0; + + return ( +
+ {snippet[0].line > 1 && ( + + )} + + + {snippet.map((line, index) => + this.renderLine({ + index, + issuesForLine: issuesByLine[line.line] || [], + issueLocations: locationsByLine[line.line] || [], + line, + snippet, + symbols: symbols[line.line], + verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0 + }) + )} + +
+ {(!lastLine || snippet[snippet.length - 1].line < lastLine) && ( + + )} +
+ ); + } + + render() { + const { branchLike, issue, issuesByLine, last, snippetGroup } = this.props; + const { loading, snippets } = this.state; + const locations = locationsByLine([issue]); + + const fullyShown = + snippets.length === 1 && + snippetGroup.component.measures && + snippets[0].length === parseInt(snippetGroup.component.measures.lines || '', 10); + + return ( +
+ + {snippets.map((snippet, index) => + this.renderSnippet({ + snippet, + index, + issue, + issuesByLine: last ? issuesByLine : {}, + locationsByLine: last && index === snippets.length - 1 ? locations : {}, + last: last && index === snippets.length - 1 + }) + )} +
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx new file mode 100644 index 00000000000..ff48d1ea91a --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx @@ -0,0 +1,26 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { lazyLoad } from '../../../components/lazyLoad'; + +const CrossComponentSourceViewer = lazyLoad(() => + import(/* webpackPrefetch: true */ './CrossComponentSourceViewerWrapper') +); + +export default CrossComponentSourceViewer; diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx new file mode 100644 index 00000000000..6f27127d2e1 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx @@ -0,0 +1,138 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import ComponentSourceSnippetViewer from './ComponentSourceSnippetViewer'; +import { groupLocationsByComponent } from './utils'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import { getIssueFlowSnippets } from '../../../api/issues'; +import { issuesByComponentAndLine } from '../../../components/SourceViewer/helpers/indexing'; + +interface State { + components: T.Dict; + issuePopup?: { issue: string; name: string }; + loading: boolean; +} + +interface Props { + branchLike: T.Branch | T.PullRequest | undefined; + highlightedLocationMessage?: { index: number; text: string | undefined }; + issue: T.Issue; + issues: T.Issue[]; + locations: T.FlowLocation[]; + onIssueChange: (issue: T.Issue) => void; + onLoaded?: () => void; + onLocationSelect: (index: number) => void; + renderDuplicationPopup: (index: number, line: number) => JSX.Element; + scroll?: (element: HTMLElement) => void; + selectedFlowIndex: number | undefined; +} + +export default class CrossComponentSourceViewerWrapper extends React.PureComponent { + mounted = false; + state: State = { + components: {}, + loading: true + }; + + componentDidMount() { + this.mounted = true; + this.fetchIssueFlowSnippets(this.props.issue.key); + } + + componentWillReceiveProps(newProps: Props) { + if (newProps.issue.key !== this.props.issue.key) { + this.fetchIssueFlowSnippets(newProps.issue.key); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchIssueFlowSnippets(issueKey: string) { + this.setState({ loading: true }); + getIssueFlowSnippets(issueKey).then( + components => { + if (this.mounted) { + this.setState({ components, issuePopup: undefined, loading: false }); + if (this.props.onLoaded) { + this.props.onLoaded(); + } + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false }); + } + } + ); + } + + handleIssuePopupToggle = (issue: string, popupName: string, open?: boolean) => { + this.setState((state: State) => { + const samePopup = + state.issuePopup && state.issuePopup.name === popupName && state.issuePopup.issue === issue; + if (open !== false && !samePopup) { + return { issuePopup: { issue, name: popupName } }; + } else if (open !== true && samePopup) { + return { issuePopup: undefined }; + } + return null; + }); + }; + + render() { + const { components, loading } = this.state; + + if (loading) { + return ( +
+ +
+ ); + } + + const issuesByComponent = issuesByComponentAndLine(this.props.issues); + const locationsByComponent = groupLocationsByComponent(this.props.locations, components); + + return ( +
+ {locationsByComponent.map((g, i) => ( + + ))} +
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx new file mode 100644 index 00000000000..6076ef40b02 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx @@ -0,0 +1,98 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import ComponentSourceSnippetViewer from '../ComponentSourceSnippetViewer'; +import { + mockMainBranch, + mockIssue, + mockSourceViewerFile, + mockFlowLocation, + mockSnippetsByComponent +} from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/components', () => { + const { mockSnippetsByComponent } = require.requireActual('../../../../helpers/testMocks'); + + return { + getSources: jest + .fn() + .mockResolvedValue( + Object.values( + mockSnippetsByComponent('a', [22, 23, 24, 25, 26, 27, 28, 29, 30, 31]).sources + ) + ) + }; +}); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should expand block', async () => { + const snippetGroup: T.SnippetGroup = { + locations: [ + mockFlowLocation({ + component: 'a', + textRange: { startLine: 34, endLine: 34, startOffset: 0, endOffset: 0 } + }), + mockFlowLocation({ + component: 'a', + textRange: { startLine: 54, endLine: 54, startOffset: 0, endOffset: 0 } + }) + ], + ...mockSnippetsByComponent('a', [32, 33, 34, 35, 36, 52, 53, 54, 55, 56]) + }; + + const wrapper = shallowRender({ snippetGroup }); + + wrapper.instance().expandBlock(0, 'up'); + await waitAndUpdate(wrapper); + + expect(wrapper.state('snippets')).toHaveLength(2); + expect(wrapper.state('snippets')[0]).toHaveLength(15); + expect(Object.keys(wrapper.state('additionalLines'))).toHaveLength(10); +}); + +function shallowRender(props: Partial = {}) { + const snippetGroup: T.SnippetGroup = { + component: mockSourceViewerFile(), + locations: [], + sources: [] + }; + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx new file mode 100644 index 00000000000..63051e1575e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx @@ -0,0 +1,73 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import CrossComponentSourceViewerWrapper from '../CrossComponentSourceViewerWrapper'; +import { mockIssue, mockSourceViewerFile } from '../../../../helpers/testMocks'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/issues', () => { + const { mockSourceViewerFile } = require.requireActual('../../../../helpers/testMocks'); + return { + getIssueFlowSnippets: jest.fn().mockResolvedValue([mockSourceViewerFile()]) + }; +}); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('Should fetch data', async () => { + const wrapper = shallowRender(); + wrapper.instance().fetchIssueFlowSnippets('124'); + await waitAndUpdate(wrapper); + + expect(wrapper.state('components')).toEqual([mockSourceViewerFile()]); +}); + +it('should handle issue popup', () => { + const wrapper = shallowRender(); + // open + wrapper.instance().handleIssuePopupToggle('1', 'popup1'); + expect(wrapper.state('issuePopup')).toEqual({ issue: '1', name: 'popup1' }); + + // close + wrapper.instance().handleIssuePopupToggle('1', 'popup1'); + expect(wrapper.state('issuePopup')).toBeUndefined(); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap new file mode 100644 index 00000000000..136d07a4e9e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +
+ +
+`; diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap new file mode 100644 index 00000000000..9283b3f518f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +
+ +
+`; diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts new file mode 100644 index 00000000000..ad114d760a3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts @@ -0,0 +1,193 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { keyBy, range } from 'lodash'; +import { groupLocationsByComponent, createSnippets, expandSnippet } from '../utils'; +import { + mockFlowLocation, + mockSnippetsByComponent, + mockSourceLine +} from '../../../../helpers/testMocks'; + +describe('groupLocationsByComponent', () => { + it('should handle empty args', () => { + expect(groupLocationsByComponent([], {})).toEqual([]); + }); + + it('should group correctly', () => { + const results = groupLocationsByComponent( + [ + mockFlowLocation({ + textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 } + }), + mockFlowLocation({ + textRange: { startLine: 16, startOffset: 2, endLine: 16, endOffset: 3 } + }), + mockFlowLocation({ + textRange: { startLine: 24, startOffset: 1, endLine: 24, endOffset: 2 } + }) + ], + { 'main.js': mockSnippetsByComponent('main.js', [14, 15, 16, 17, 18, 22, 23, 24, 25, 26]) } + ); + + expect(results).toHaveLength(1); + }); + + it('should preserve step order when jumping between files', () => { + const results = groupLocationsByComponent( + [ + mockFlowLocation({ + component: 'A.js', + textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 } + }), + mockFlowLocation({ + component: 'B.js', + textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 } + }), + mockFlowLocation({ + component: 'A.js', + textRange: { startLine: 15, startOffset: 2, endLine: 15, endOffset: 3 } + }) + ], + { + 'A.js': mockSnippetsByComponent('A.js', [13, 14, 15, 16, 17, 18]), + 'B.js': mockSnippetsByComponent('B.js', [14, 15, 16, 17, 18]) + } + ); + + expect(results).toHaveLength(3); + expect(results[0].component.key).toBe('A.js'); + expect(results[1].component.key).toBe('B.js'); + expect(results[2].component.key).toBe('A.js'); + expect(results[0].locations).toHaveLength(1); + expect(results[1].locations).toHaveLength(1); + expect(results[2].locations).toHaveLength(1); + }); +}); + +describe('createSnippets', () => { + it('should merge snippets correctly', () => { + const results = createSnippets( + [ + mockFlowLocation({ + textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 } + }), + mockFlowLocation({ + textRange: { startLine: 19, startOffset: 2, endLine: 19, endOffset: 3 } + }) + ], + mockSnippetsByComponent('', [14, 15, 16, 17, 18, 19, 20, 21, 22]).sources, + false + ); + + expect(results).toHaveLength(1); + expect(results[0]).toHaveLength(8); + }); + + it('should merge snippets correctly, even when not in sequence', () => { + const results = createSnippets( + [ + mockFlowLocation({ + textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 } + }), + mockFlowLocation({ + textRange: { startLine: 47, startOffset: 2, endLine: 47, endOffset: 3 } + }), + mockFlowLocation({ + textRange: { startLine: 14, startOffset: 2, endLine: 14, endOffset: 3 } + }) + ], + mockSnippetsByComponent('', [12, 13, 14, 15, 16, 17, 18, 45, 46, 47, 48, 49]).sources, + false + ); + + expect(results).toHaveLength(2); + expect(results[0]).toHaveLength(7); + expect(results[1]).toHaveLength(5); + }); + + it('should merge three snippets together', () => { + const results = createSnippets( + [ + mockFlowLocation({ + textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 } + }), + mockFlowLocation({ + textRange: { startLine: 47, startOffset: 2, endLine: 47, endOffset: 3 } + }), + mockFlowLocation({ + textRange: { startLine: 22, startOffset: 2, endLine: 22, endOffset: 3 } + }), + mockFlowLocation({ + textRange: { startLine: 18, startOffset: 2, endLine: 18, endOffset: 3 } + }) + ], + mockSnippetsByComponent('', [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 45, 46, 47, 48, 49]) + .sources, + false + ); + + expect(results).toHaveLength(2); + expect(results[0]).toHaveLength(11); + expect(results[1]).toHaveLength(5); + }); +}); + +describe('expandSnippet', () => { + it('should add lines above', () => { + const lines = keyBy(range(4, 19).map(line => mockSourceLine({ line })), 'line'); + const snippets = [[lines[14], lines[15], lines[16], lines[17], lines[18]]]; + + const result = expandSnippet({ direction: 'up', lines, snippetIndex: 0, snippets }); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(15); + expect(result[0].map(l => l.line)).toEqual(range(4, 19)); + }); + + it('should add lines below', () => { + const lines = keyBy(range(4, 19).map(line => mockSourceLine({ line })), 'line'); + const snippets = [[lines[4], lines[5], lines[6], lines[7], lines[8]]]; + + const result = expandSnippet({ direction: 'down', lines, snippetIndex: 0, snippets }); + + expect(result).toHaveLength(1); + expect(result[0].map(l => l.line)).toEqual(range(4, 19)); + }); + + it('should merge snippets if necessary', () => { + const lines = keyBy( + range(4, 23) + .concat(range(38, 43)) + .map(line => mockSourceLine({ line })), + 'line' + ); + const snippets = [ + [lines[4], lines[5], lines[6], lines[7], lines[8]], + [lines[38], lines[39], lines[40], lines[41], lines[42]], + [lines[17], lines[18], lines[19], lines[20], lines[21]] + ]; + + const result = expandSnippet({ direction: 'down', lines, snippetIndex: 0, snippets }); + + expect(result).toHaveLength(2); + expect(result[0].map(l => l.line)).toEqual(range(4, 22)); + expect(result[1].map(l => l.line)).toEqual(range(38, 43)); + }); +}); 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 new file mode 100644 index 00000000000..5c34f42dfbb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts @@ -0,0 +1,185 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +const LINES_ABOVE = 2; +const LINES_BELOW = 2; +export const MERGE_DISTANCE = 4; // Merge if snippets are four lines away (separated by 3 lines) or fewer +export const LINES_BELOW_LAST = 9; +export const EXPAND_BY_LINES = 10; + +function unknownComponent(key: string): T.SnippetsByComponent { + return { + component: { + key, + measures: {}, + path: '', + project: '', + projectName: '', + q: 'FIL', + uuid: '' + }, + sources: [] + }; +} + +function collision([startA, endA]: number[], [startB, endB]: number[]) { + return !(startA > endB + MERGE_DISTANCE || endA < startB - MERGE_DISTANCE); +} + +export function createSnippets( + locations: T.FlowLocation[], + componentLines: T.LineMap = {}, + last: boolean +): T.SourceLine[][] { + return rangesToSnippets( + // For each location's range (2 above and 2 below), and then compare with other ranges + // to merge snippets that collide. + locations.reduce((snippets: Array<{ start: number; end: number }>, loc, index) => { + const startIndex = Math.max(1, loc.textRange.startLine - LINES_ABOVE); + const endIndex = + loc.textRange.endLine + + (last && index === locations.length - 1 ? LINES_BELOW_LAST : LINES_BELOW); + + let firstCollision: { start: number; end: number } | undefined; + + // Remove ranges that collide into the first collision + snippets = snippets.filter(snippet => { + if (collision([snippet.start, snippet.end], [startIndex, endIndex])) { + let keep = false; + // Check if we've already collided + if (!firstCollision) { + firstCollision = snippet; + keep = true; + } + // Merge with first collision: + firstCollision.start = Math.min(startIndex, snippet.start, firstCollision.start); + firstCollision.end = Math.max(endIndex, snippet.end, firstCollision.end); + + // remove the range if it was not the first collision + return keep; + } + return true; + }); + + if (firstCollision === undefined) { + snippets.push({ + start: startIndex, + end: endIndex + }); + } + + return snippets; + }, []), + componentLines + ); +} + +function rangesToSnippets( + ranges: Array<{ start: number; end: number }>, + componentLines: T.LineMap +) { + return ranges + .map(range => { + const lines = []; + for (let i = range.start; i <= range.end; i++) { + if (componentLines[i]) { + lines.push(componentLines[i]); + } + } + return lines; + }) + .filter(snippet => snippet.length > 0); +} + +export function groupLocationsByComponent( + locations: T.FlowLocation[], + components: { [key: string]: T.SnippetsByComponent } +) { + let currentComponent = ''; + let currentGroup: T.SnippetGroup; + const groups: T.SnippetGroup[] = []; + + locations.forEach((loc, index) => { + if (loc.component !== currentComponent) { + currentGroup = { + ...(components[loc.component] || unknownComponent(loc.component)), + locations: [] + }; + groups.push(currentGroup); + currentComponent = loc.component; + } + loc.index = index; + currentGroup.locations.push(loc); + }); + + return groups; +} + +export function expandSnippet({ + direction, + lines, + snippetIndex, + snippets +}: { + direction: T.ExpandDirection; + lines: T.LineMap; + snippetIndex: number; + snippets: T.SourceLine[][]; +}) { + const snippetToExpand = snippets[snippetIndex]; + + const snippetToExpandRange = { + start: Math.max(0, snippetToExpand[0].line - (direction === 'up' ? EXPAND_BY_LINES : 0)), + end: + snippetToExpand[snippetToExpand.length - 1].line + + (direction === 'down' ? EXPAND_BY_LINES : 0) + }; + + const ranges: Array<{ start: number; end: number }> = []; + + snippets.forEach((snippet, index: number) => { + const snippetRange = { + start: snippet[0].line, + end: snippet[snippet.length - 1].line + }; + + if (index === snippetIndex) { + // keep expanded snippet + ranges.push(snippetToExpandRange); + } else if ( + collision( + [snippetRange.start, snippetRange.end], + [snippetToExpandRange.start, snippetToExpandRange.end] + ) + ) { + // Merge with expanded snippet + snippetToExpandRange.start = Math.min(snippetRange.start, snippetToExpandRange.start); + snippetToExpandRange.end = Math.max(snippetRange.end, snippetToExpandRange.end); + } else { + // No collision, jsut keep the snippet + ranges.push(snippetRange); + } + }); + + return rangesToSnippets(ranges, lines); +} + +export function inSnippet(line: number, snippet: T.SourceLine[]) { + return line >= snippet[0].line && line <= snippet[snippet.length - 1].line; +} diff --git a/server/sonar-web/src/main/js/apps/issues/styles.css b/server/sonar-web/src/main/js/apps/issues/styles.css index 1f2dba6d6a5..1cb4a7e4973 100644 --- a/server/sonar-web/src/main/js/apps/issues/styles.css +++ b/server/sonar-web/src/main/js/apps/issues/styles.css @@ -225,6 +225,45 @@ border-color: rgba(209, 133, 130, 0.6); } +.component-source-container { + border: 1px solid var(--gray80); +} + +.component-source-container + .component-source-container { + margin-top: var(--gridSize); +} + +.component-source-container-header { + background-color: var(--gray94); + padding: var(--gridSize); +} + +.snippet { + margin: var(--gridSize); + border: 1px solid var(--gray80); + overflow-x: auto; +} + +.snippet > .expand-block { + box-sizing: border-box; + color: var(--secondFontColor); + height: 20px; + width: 100%; + padding: calc(var(--gridSize) / 4); + border: 0; + text-align: left; + cursor: pointer; +} +.snippet > .expand-block:hover { + color: var(--darkBlue); +} +.snippet > .expand-block-above { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII='); +} +.snippet > .expand-block-below { + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wQQBjQEQVd5jwAAADhJREFUCNddyTEKADEMA8GVA/7/Z+PGwUp1cGTaYe/tv5lxrLWoKj6SiMzkjZDEG7JtANt0N+ccLrB/KZxXTt7fAAAAAElFTkSuQmCC'); +} + .issues-my-issues-filter { margin-bottom: 24px; text-align: center; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx index 50164b72dc4..10341ecd3b1 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx @@ -20,8 +20,9 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { intersection, uniqBy } from 'lodash'; -import SourceViewerHeader from './SourceViewerHeader'; import SourceViewerCode from './SourceViewerCode'; +import SourceViewerHeader from './SourceViewerHeader'; +import SourceViewerHeaderSlim from './SourceViewerHeaderSlim'; import { SourceViewerContext } from './SourceViewerContext'; import DuplicationPopup from './components/DuplicationPopup'; import defaultLoadIssues from './helpers/loadIssues'; @@ -81,6 +82,7 @@ export interface Props { scroll?: (element: HTMLElement) => void; selectedIssue?: string; showMeasures?: boolean; + slimHeader?: boolean; } interface State { @@ -667,6 +669,24 @@ export default class SourceViewerBase extends React.PureComponent ); } + renderHeader(branchLike: T.BranchLike | undefined, sourceViewerFile: T.SourceViewerFile) { + return this.props.slimHeader ? ( + + ) : ( + + {({ openComponent }) => ( + + )} + + ); + } + render() { const { component, loading, sources, notAccessible, sourceRemoved } = this.state; @@ -701,17 +721,7 @@ export default class SourceViewerBase extends React.PureComponent return (
(this.node = node)}> - - {({ openComponent }) => ( - - )} - + {this.renderHeader(this.props.branchLike, component)} {sourceRemoved && ( {translate('code_viewer.no_source_code_displayed_due_to_source_removed')} 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 cac8ee2dec8..04ecb86b671 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx @@ -18,9 +18,13 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { intersection } from 'lodash'; import Line from './components/Line'; -import { getLinearLocations } from './helpers/issueLocations'; +import { getSecondaryIssueLocationsForLine } from './helpers/issueLocations'; +import { + optimizeSelectedIssue, + optimizeLocationMessage, + optimizeHighlightedSymbols +} from './helpers/lines'; import { translate } from '../../helpers/l10n'; import { Button } from '../ui/buttons'; @@ -88,21 +92,6 @@ export default class SourceViewerCode extends React.PureComponent { return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY; }; - getSecondaryIssueLocationsForLine = (line: T.SourceLine): T.LinearIssueLocation[] => { - const { highlightedLocations } = this.props; - if (!highlightedLocations) { - return EMPTY_ARRAY; - } - return highlightedLocations.reduce((locations, location, index) => { - const linearLocations: T.LinearIssueLocation[] = location - ? getLinearLocations(location.textRange) - .filter(l => l.line === line.line) - .map(l => ({ ...l, startLine: location.textRange.startLine, index })) - : []; - return [...locations, ...linearLocations]; - }, []); - }; - renderLine = ({ line, index, @@ -116,41 +105,14 @@ export default class SourceViewerCode extends React.PureComponent { displayDuplications: boolean; displayIssues: boolean; }) => { - const { highlightedLocationMessage, selectedIssue, sources } = this.props; + const { highlightedLocationMessage, highlightedLocations, selectedIssue, sources } = this.props; - const secondaryIssueLocations = this.getSecondaryIssueLocationsForLine(line); + const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, highlightedLocations); const duplicationsCount = this.props.duplications ? this.props.duplications.length : 0; const issuesForLine = this.getIssuesForLine(line); - // for the following properties pass null if the line for sure is not impacted - const symbolsForLine = this.props.symbolsByLine[line.line] || []; - const { highlightedSymbols } = this.props; - let optimizedHighlightedSymbols: string[] | undefined = intersection( - symbolsForLine, - highlightedSymbols - ); - if (!optimizedHighlightedSymbols.length) { - optimizedHighlightedSymbols = undefined; - } - - const optimizedSelectedIssue = - selectedIssue !== undefined && issuesForLine.find(issue => issue.key === selectedIssue) - ? selectedIssue - : undefined; - - const optimizedSecondaryIssueLocations = - secondaryIssueLocations.length > 0 ? secondaryIssueLocations : EMPTY_ARRAY; - - const optimizedLocationMessage = - highlightedLocationMessage != null && - optimizedSecondaryIssueLocations.some( - location => location.index === highlightedLocationMessage.index - ) - ? highlightedLocationMessage - : undefined; - return ( { duplications={this.getDuplicationsForLine(line)} duplicationsCount={duplicationsCount} highlighted={line.line === this.props.highlightedLine} - highlightedLocationMessage={optimizedLocationMessage} - highlightedSymbols={optimizedHighlightedSymbols} + highlightedLocationMessage={optimizeLocationMessage( + highlightedLocationMessage, + secondaryIssueLocations + )} + highlightedSymbols={optimizeHighlightedSymbols( + this.props.symbolsByLine[line.line], + this.props.highlightedSymbols + )} issueLocations={this.getIssueLocationsForLine(line)} issuePopup={this.props.issuePopup} issues={issuesForLine} @@ -185,8 +153,8 @@ export default class SourceViewerCode extends React.PureComponent { previousLine={index > 0 ? sources[index - 1] : undefined} renderDuplicationPopup={this.props.renderDuplicationPopup} scroll={this.props.scroll} - secondaryIssueLocations={optimizedSecondaryIssueLocations} - selectedIssue={optimizedSelectedIssue} + secondaryIssueLocations={secondaryIssueLocations} + selectedIssue={optimizeSelectedIssue(selectedIssue, issuesForLine)} /> ); }; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx index 4f367fdf5ac..c9f23440479 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx @@ -97,7 +97,7 @@ export default class SourceViewerHeader extends React.PureComponent
- {subProject != null && ( + {subProject !== undefined && (
{subProjectName}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css new file mode 100644 index 00000000000..eaaa3d7deb9 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css @@ -0,0 +1,30 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +.source-viewer-header-slim { + padding: 4px 10px 4px; + border-bottom: 1px solid var(--gray80); + background-color: var(--barBackgroundColor); + align-items: center; + min-height: 25px; +} + +.source-viewer-header-slim-actions { + margin-left: calc(3 * var(--gridSize)); +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx new file mode 100644 index 00000000000..167472a692a --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx @@ -0,0 +1,92 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import DeferredSpinner from '../common/DeferredSpinner'; +import Favorite from '../controls/Favorite'; +import ExpandSnippetIcon from '../icons-components/ExpandSnippetIcon'; +import QualifierIcon from '../icons-components/QualifierIcon'; +import { ButtonIcon } from '../ui/buttons'; +import { getPathUrlAsString, getBranchLikeUrl } from '../../helpers/urls'; +import { collapsedDirFromPath, fileFromPath } from '../../helpers/path'; +import { isMainBranch } from '../../helpers/branches'; +import './SourceViewerHeaderSlim.css'; + +interface Props { + branchLike: T.BranchLike | undefined; + expandable?: boolean; + loading?: boolean; + onExpand?: () => void; + sourceViewerFile: T.SourceViewerFile; +} + +export default function SourceViewerHeaderSlim({ + branchLike, + expandable, + loading, + onExpand, + sourceViewerFile +}: Props) { + const { key, path, project, projectName, q, subProject, subProjectName } = sourceViewerFile; + + return ( +
+
+ + + {subProject !== undefined && ( +
+ {subProjectName} +
+ )} + +
+ {collapsedDirFromPath(path)} + {fileFromPath(path)} +
+ {sourceViewerFile.canMarkAsFavorite && (!branchLike || isMainBranch(branchLike)) && ( +
+ +
+ )} +
+ + {expandable && ( + +
+ + + +
+
+ )} +
+ ); +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.css b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.css new file mode 100644 index 00000000000..0c034c301ae --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.css @@ -0,0 +1,257 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +.source-line:hover .source-line-number, +.source-line:hover .source-line-issues, +.source-line:hover .source-line-coverage, +.source-line:hover .source-line-duplications, +.source-line:hover .source-line-duplications-extra, +.source-line:hover .source-line-scm { + border-color: #e9e9e9; + background-color: #e9e9e9; +} + +.source-line:hover .source-line-code { + background-color: #f5f5f5; +} + +.source-line-highlighted .source-line-number, +.source-line-highlighted:hover .source-line-number, +.source-line-highlighted .source-line-issues, +.source-line-highlighted:hover .source-line-issues, +.source-line-highlighted .source-line-coverage, +.source-line-highlighted:hover .source-line-coverage, +.source-line-highlighted .source-line-duplications, +.source-line-highlighted:hover .source-line-duplications, +.source-line-highlighted .source-line-duplications-extra, +.source-line-highlighted:hover .source-line-duplications-extra, +.source-line-highlighted .source-line-scm, +.source-line-highlighted:hover .source-line-scm { + border-color: #c4dfec !important; + background-color: #c4dfec; +} + +.source-line-highlighted .source-line-code, +.source-line-highlighted:hover .source-line-code { + background-color: #d9edf7; +} + +.source-line-filtered .source-line-code { + background-color: var(--leakColor) !important; +} + +.source-line-filtered.source-line-highlighted .source-line-code, +.source-line-filtered.source-line-highlighted:hover .source-line-code { + background-color: #cdd9c4 !important; +} + +.source-line-filtered:hover .source-line-code { + background-color: #f1e8cb !important; +} + +.source-line-filtered.source-line-filtered-dark .source-line-code { + background-color: #f9ebb7 !important; +} + +.source-line-filtered.source-line-filtered-dark:hover .source-line-code { + background-color: #eaddb2 !important; +} + +.source-line-last .source-line-code { + padding-bottom: 160px; +} + +.source-viewer pre { + height: 18px; + padding: 0; +} + +.source-viewer pre, +.source-line-number, +.source-line-scm { + line-height: 18px; + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: var(--smallFontSize); +} + +.source-line-code { + position: relative; + padding: 0 10px; +} + +.source-line-code pre { + float: left; +} + +.source-line-code .issue-list { + margin-left: -10px; + margin-right: -10px; +} + +.source-line-code-inner { + min-height: 18px; +} + +.source-line-code-inner:before, +.source-line-code-inner:after { + display: table; + content: ''; + line-height: 0; +} + +.source-line-code-inner:after { + clear: both; +} + +.source-line-code-issue { + display: inline-block; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAGCAYAAAAPDoR2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo1M0M2Rjk4M0M3QUYxMUUzODkzRUREMUM5OTNDMjY4QSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo1M0M2Rjk4NEM3QUYxMUUzODkzRUREMUM5OTNDMjY4QSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjUzQzZGOTgxQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjUzQzZGOTgyQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+bcqJtQAAAEhJREFUeNpi+G+swwDGDAwgbAWlwZiJAQFCgfgwEIfDRaC67ID4NRDnQ2kQnwFZwgFqnANMAQOUYY9sF0wBiCGH5CBkrAgQYACuWi4sSGW8yAAAAABJRU5ErkJggg==); + background-repeat: repeat-x; + background-size: 4px; + background-position: bottom; +} + +.source-meta { + position: relative; + vertical-align: top; + width: 1px; + background-clip: padding-box; + user-select: none; +} + +.source-meta:focus { + outline: none; +} + +.source-meta[role='button'] { + cursor: pointer; +} + +.source-meta + .source-meta { + border-left: 1px solid var(--barBackgroundColor); +} + +.source-line-number { + min-width: 18px; + padding: 0 10px; + background-color: var(--barBackgroundColor); + color: var(--secondFontColor); + text-align: right; +} + +.source-line-number:before { + content: attr(data-line-number); +} + +.source-line-issues { + position: relative; + padding: 0 2px; + background-color: var(--barBackgroundColor); + white-space: nowrap; +} + +.source-line-with-issues { + padding-right: 4px; +} + +.source-line-issues-counter { + position: absolute; + left: 17px; + line-height: 8px; + font-size: 8px; + z-index: 900; +} + +.source-line-coverage { + background-color: var(--barBackgroundColor); +} + +.source-line-duplications, +.source-line-duplications-extra { + background-color: var(--barBackgroundColor); +} + +.source-line-duplications-extra { + display: none; +} + +.source-duplications-expanded .source-line-duplications { + display: none; +} + +.source-duplications-expanded .source-line-duplications-extra { + display: table-cell; +} + +.source-line-scm { + padding: 0 5px; + background-color: var(--barBackgroundColor); +} + +.source-line-scm-inner { + max-width: 40px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-line-scm-inner:before { + content: attr(data-author); +} + +.source-line-bar { + width: 5px; + height: 18px; +} + +.source-line-bar[role='button'] { + cursor: pointer; +} + +.source-line-bar:focus { + outline: none; +} + +.source-line-covered { + background-color: var(--lineCoverageGreen) !important; +} + +.source-line-uncovered { + background-color: var(--lineCoverageRed) !important; +} + +.source-line-partially-covered { + background-color: var(--lineCoverageRed) !important; + background-image: repeating-linear-gradient( + 45deg, + rgba(255, 255, 255, 0.5) 4px, + transparent 4px, + transparent 8px, + rgba(255, 255, 255, 0.5) 8px, + rgba(255, 255, 255, 0.5) 12px, + transparent 12px, + transparent 16px, + rgba(255, 255, 255, 0.5) 16px, + rgba(255, 255, 255, 0.5) 20px + ) !important; +} + +.source-line-duplicated { + background-color: #797979 !important; +} 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 5d26fe77b9b..79e938a542c 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 @@ -27,6 +27,7 @@ import LineDuplications from './LineDuplications'; import LineDuplicationBlock from './LineDuplicationBlock'; import LineIssuesIndicator from './LineIssuesIndicator'; import LineCode from './LineCode'; +import './Line.css'; interface Props { branchLike: T.BranchLike | undefined; @@ -60,16 +61,13 @@ interface Props { previousLine: T.SourceLine | undefined; renderDuplicationPopup: (index: number, line: number) => JSX.Element; scroll?: (element: HTMLElement) => void; - secondaryIssueLocations: Array<{ - from: number; - to: number; - line: number; - index: number; - startLine: number; - }>; + secondaryIssueLocations: T.LinearIssueLocation[]; selectedIssue: string | undefined; + verticalBuffer?: number; } +const LINE_HEIGHT = 18; + export default class Line extends React.PureComponent { isPopupOpen = (name: string, index?: number) => { const { line, linePopup } = this.props; @@ -103,9 +101,13 @@ export default class Line extends React.PureComponent { 'source-line-filtered-dark': displayCoverage && (line.coverageStatus === 'uncovered' || line.coverageStatus === 'partially-covered'), - 'source-line-last': this.props.last + 'source-line-last': this.props.last === true }); + const bottomPadding = this.props.verticalBuffer + ? this.props.verticalBuffer * LINE_HEIGHT + : undefined; + return ( { previousLine={this.props.previousLine} /> - {this.props.displayIssues && !this.props.displayAllIssues && ( + {this.props.displayIssues && !this.props.displayAllIssues ? ( + ) : ( + )} {this.props.displayDuplications && ( @@ -161,6 +165,7 @@ export default class Line extends React.PureComponent { onIssueSelect={this.props.onIssueSelect} onLocationSelect={this.props.onLocationSelect} onSymbolClick={this.props.onSymbolClick} + padding={bottomPadding} scroll={this.props.scroll} secondaryIssueLocations={this.props.secondaryIssueLocations} selectedIssue={this.props.selectedIssue} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx index 7b9bd25fba9..485353c9626 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx @@ -43,14 +43,9 @@ interface Props { onIssueSelect: (issueKey: string) => void; onLocationSelect: ((index: number) => void) | undefined; onSymbolClick: (symbols: Array) => void; + padding?: number; scroll?: (element: HTMLElement) => void; - secondaryIssueLocations: Array<{ - from: number; - to: number; - line: number; - index: number; - startLine: number; - }>; + secondaryIssueLocations: T.LinearIssueLocation[]; selectedIssue: string | undefined; showIssues?: boolean; } @@ -94,7 +89,9 @@ export default class LineCode extends React.PureComponent { this.attachEvents(); if ( this.props.highlightedLocationMessage && - prevProps.highlightedLocationMessage !== this.props.highlightedLocationMessage && + (!prevProps.highlightedLocationMessage || + prevProps.highlightedLocationMessage.index !== + this.props.highlightedLocationMessage.index) && this.activeMarkerNode && this.props.scroll ) { @@ -159,6 +156,7 @@ export default class LineCode extends React.PureComponent { issueLocations, line, onIssueSelect, + padding, secondaryIssueLocations, selectedIssue, showIssues @@ -204,7 +202,8 @@ export default class LineCode extends React.PureComponent { token.markers.forEach(marker => { const selected = highlightedLocationMessage !== undefined && highlightedLocationMessage.index === marker; - const message = selected ? highlightedLocationMessage!.text : undefined; + const loc = secondaryIssueLocations.find(loc => loc.index === marker); + const message = loc && loc.text; renderedTokens.push(this.renderMarker(marker, message, selected, leadingMarker)); }); } @@ -218,8 +217,14 @@ export default class LineCode extends React.PureComponent { leadingMarker = (index === 0 ? true : leadingMarker) && !token.text.trim().length; }); + const style = padding + ? { + paddingBottom: padding + 'px' + } + : undefined; + return ( - +
 (this.codeNode = node)}>{renderedTokens}
@@ -234,6 +239,17 @@ export default class LineCode extends React.PureComponent { selectedIssue={selectedIssue} /> )} + {selectedIssue && !showIssues && ( + i.key === selectedIssue)} + onIssueChange={this.props.onIssueChange} + onIssueClick={onIssueSelect} + onIssuePopupToggle={this.props.onIssuePopupToggle} + selectedIssue={selectedIssue} + /> + )} ); } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx index 0281bf0f863..084cba155fa 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx @@ -92,8 +92,6 @@ function shallowRender(props: Partial = {}) { displayAllIssues={false} displayCoverage={false} displayDuplications={false} - displayIssueLocationsCount={false} - displayIssueLocationsLink={false} displayIssues={false} displayLocationMarkers={false} duplications={[]} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap index cc374a63141..fee4f4dd800 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap @@ -2,16 +2,21 @@ exports[`should render correctly 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -20,15 +25,23 @@ exports[`should render correctly 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} popupOpen={false} /> + import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onIssueChange={[MockFunction]} @@ -133,16 +149,20 @@ exports[`should render correctly 1`] = ` exports[`should render correctly for last, new, and highlighted lines 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, + "duplicated": false, "isNew": true, - "line": 5, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -151,16 +171,23 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, + "duplicated": false, "isNew": true, - "line": 5, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} popupOpen={false} /> + import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, + "duplicated": false, "isNew": true, - "line": 5, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onIssueChange={[MockFunction]} @@ -265,16 +294,21 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = ` exports[`should render correctly with coverage 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -283,22 +317,35 @@ exports[`should render correctly with coverage 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} popupOpen={false} /> + import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } /> @@ -313,8 +360,6 @@ exports[`should render correctly with coverage 1`] = ` "title": "Foo Bar feature", } } - displayIssueLocationsCount={false} - displayIssueLocationsLink={false} displayLocationMarkers={false} issueLocations={Array []} issues={ @@ -385,10 +430,15 @@ exports[`should render correctly with coverage 1`] = ` } line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onIssueChange={[MockFunction]} @@ -405,16 +455,21 @@ exports[`should render correctly with coverage 1`] = ` exports[`should render correctly with duplication information 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -423,22 +478,35 @@ exports[`should render correctly with duplication information 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} popupOpen={false} /> + import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onClick={[MockFunction]} @@ -449,10 +517,15 @@ exports[`should render correctly with duplication information 1`] = ` key="0" line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -465,10 +538,15 @@ exports[`should render correctly with duplication information 1`] = ` key="1" line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -481,10 +559,15 @@ exports[`should render correctly with duplication information 1`] = ` key="2" line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -502,8 +585,6 @@ exports[`should render correctly with duplication information 1`] = ` "title": "Foo Bar feature", } } - displayIssueLocationsCount={false} - displayIssueLocationsLink={false} displayLocationMarkers={false} issueLocations={Array []} issues={ @@ -574,10 +655,15 @@ exports[`should render correctly with duplication information 1`] = ` } line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onIssueChange={[MockFunction]} @@ -594,16 +680,21 @@ exports[`should render correctly with duplication information 1`] = ` exports[`should render correctly with issues info 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -612,10 +703,15 @@ exports[`should render correctly with issues info 1`] = ` import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onPopupToggle={[MockFunction]} @@ -690,10 +786,15 @@ exports[`should render correctly with issues info 1`] = ` } line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onClick={[Function]} @@ -709,8 +810,6 @@ exports[`should render correctly with issues info 1`] = ` "title": "Foo Bar feature", } } - displayIssueLocationsCount={false} - displayIssueLocationsLink={false} displayLocationMarkers={false} issueLocations={Array []} issues={ @@ -781,10 +880,15 @@ exports[`should render correctly with issues info 1`] = ` } line={ Object { - "code": "function fooBar() {", + "code": "import java.util.ArrayList;", "coverageStatus": "covered", "coveredConditions": 2, - "line": 5, + "duplicated": false, + "isNew": true, + "line": 16, + "scmAuthor": "simon.brandhof@sonarsource.com", + "scmDate": "2018-12-11T10:48:39+0100", + "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", } } onIssueChange={[MockFunction]} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts index 23d0b0221ba..12f156294bd 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts @@ -33,6 +33,18 @@ export function issuesByLine(issues: T.Issue[]) { return index; } +export function issuesByComponentAndLine( + issues: T.Issue[] = [] +): { [component: string]: { [line: number]: T.Issue[] } } { + return issues.reduce((mapping: { [component: string]: { [line: number]: T.Issue[] } }, issue) => { + mapping[issue.component] = mapping[issue.component] || {}; + const line = issue.textRange ? issue.textRange.endLine : 0; + mapping[issue.component][line] = mapping[issue.component][line] || []; + mapping[issue.component][line].push(issue); + return mapping; + }, {}); +} + export function locationsByLine(issues: T.Issue[]) { const index: { [line: number]: T.LinearIssueLocation[] } = {}; issues.forEach(issue => { diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx b/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx index 67cfae3cbf6..9218ea9168e 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx @@ -32,3 +32,25 @@ export function getLinearLocations(textRange: T.TextRange | undefined): T.Linear } return locations; } + +export function getSecondaryIssueLocationsForLine( + line: T.SourceLine, + highlightedLocations: (T.FlowLocation | undefined)[] | undefined +): T.LinearIssueLocation[] { + if (!highlightedLocations) { + return []; + } + return highlightedLocations.reduce((locations, location) => { + const linearLocations: T.LinearIssueLocation[] = location + ? getLinearLocations(location.textRange) + .filter(l => l.line === line.line) + .map(l => ({ + ...l, + startLine: location.textRange.startLine, + index: location.index, + text: location.msg + })) + : []; + return [...locations, ...linearLocations]; + }, []); +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts new file mode 100644 index 00000000000..b4de838c21d --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { intersection } from 'lodash'; + +export function optimizeHighlightedSymbols( + symbolsForLine: string[] = [], + highlightedSymbols: string[] = [] +): string[] | undefined { + const symbols = intersection(symbolsForLine, highlightedSymbols); + + return symbols.length ? symbols : undefined; +} + +export function optimizeLocationMessage( + highlightedLocationMessage: { index: number; text: string | undefined } | undefined, + optimizedSecondaryIssueLocations: T.LinearIssueLocation[] +) { + return highlightedLocationMessage != null && + optimizedSecondaryIssueLocations.some( + location => location.index === highlightedLocationMessage.index + ) + ? highlightedLocationMessage + : undefined; +} + +export function optimizeSelectedIssue(selectedIssue: string | undefined, issuesForLine: T.Issue[]) { + return selectedIssue !== undefined && issuesForLine.find(issue => issue.key === selectedIssue) + ? selectedIssue + : undefined; +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/styles.css b/server/sonar-web/src/main/js/components/SourceViewer/styles.css index 84bd8a63a3a..db5f812a00b 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/styles.css +++ b/server/sonar-web/src/main/js/components/SourceViewer/styles.css @@ -32,241 +32,6 @@ border-collapse: collapse; } -.source-line:hover .source-line-number, -.source-line:hover .source-line-issues, -.source-line:hover .source-line-coverage, -.source-line:hover .source-line-duplications, -.source-line:hover .source-line-duplications-extra, -.source-line:hover .source-line-scm { - border-color: #e9e9e9; - background-color: #e9e9e9; -} - -.source-line:hover .source-line-code { - background-color: #f5f5f5; -} - -.source-line-highlighted .source-line-number, -.source-line-highlighted:hover .source-line-number, -.source-line-highlighted .source-line-issues, -.source-line-highlighted:hover .source-line-issues, -.source-line-highlighted .source-line-coverage, -.source-line-highlighted:hover .source-line-coverage, -.source-line-highlighted .source-line-duplications, -.source-line-highlighted:hover .source-line-duplications, -.source-line-highlighted .source-line-duplications-extra, -.source-line-highlighted:hover .source-line-duplications-extra, -.source-line-highlighted .source-line-scm, -.source-line-highlighted:hover .source-line-scm { - border-color: #c4dfec !important; - background-color: #c4dfec; -} - -.source-line-highlighted .source-line-code, -.source-line-highlighted:hover .source-line-code { - background-color: #d9edf7; -} - -.source-line-filtered .source-line-code { - background-color: var(--leakColor) !important; -} - -.source-line-filtered.source-line-highlighted .source-line-code, -.source-line-filtered.source-line-highlighted:hover .source-line-code { - background-color: #cdd9c4 !important; -} - -.source-line-filtered:hover .source-line-code { - background-color: #f1e8cb !important; -} - -.source-line-filtered.source-line-filtered-dark .source-line-code { - background-color: #f9ebb7 !important; -} - -.source-line-filtered.source-line-filtered-dark:hover .source-line-code { - background-color: #eaddb2 !important; -} - -.source-line-last .source-line-code { - padding-bottom: 160px; -} - -.source-viewer pre { - height: 18px; - padding: 0; -} - -.source-viewer pre, -.source-line-number, -.source-line-scm { - line-height: 18px; - font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; - font-size: var(--smallFontSize); -} - -.source-line-code { - position: relative; - padding: 0 10px; -} - -.source-line-code pre { - float: left; -} - -.source-line-code .issue-list { - margin-left: -10px; - margin-right: -10px; -} - -.source-line-code-inner:before, -.source-line-code-inner:after { - display: table; - content: ''; - line-height: 0; -} - -.source-line-code-inner:after { - clear: both; -} - -.source-line-code-issue { - display: inline-block; - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAGCAYAAAAPDoR2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo1M0M2Rjk4M0M3QUYxMUUzODkzRUREMUM5OTNDMjY4QSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo1M0M2Rjk4NEM3QUYxMUUzODkzRUREMUM5OTNDMjY4QSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjUzQzZGOTgxQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjUzQzZGOTgyQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+bcqJtQAAAEhJREFUeNpi+G+swwDGDAwgbAWlwZiJAQFCgfgwEIfDRaC67ID4NRDnQ2kQnwFZwgFqnANMAQOUYY9sF0wBiCGH5CBkrAgQYACuWi4sSGW8yAAAAABJRU5ErkJggg==); - background-repeat: repeat-x; - background-size: 4px; - background-position: bottom; -} - -.source-meta { - position: relative; - vertical-align: top; - width: 1px; - background-clip: padding-box; - user-select: none; -} - -.source-meta:focus { - outline: none; -} - -.source-meta[role='button'] { - cursor: pointer; -} - -.source-meta + .source-meta { - border-left: 1px solid var(--barBackgroundColor); -} - -.source-line-number { - min-width: 18px; - padding: 0 10px; - background-color: var(--barBackgroundColor); - color: var(--secondFontColor); - text-align: right; -} - -.source-line-number:before { - content: attr(data-line-number); -} - -.source-line-issues { - position: relative; - padding: 0 2px; - background-color: var(--barBackgroundColor); - white-space: nowrap; -} - -.source-line-with-issues { - padding-right: 4px; -} - -.source-line-issues-counter { - position: absolute; - left: 17px; - line-height: 8px; - font-size: 8px; - z-index: 900; -} - -.source-line-coverage { - background-color: var(--barBackgroundColor); -} - -.source-line-duplications, -.source-line-duplications-extra { - background-color: var(--barBackgroundColor); -} - -.source-line-duplications-extra { - display: none; -} - -.source-duplications-expanded .source-line-duplications { - display: none; -} - -.source-duplications-expanded .source-line-duplications-extra { - display: table-cell; -} - -.source-line-scm { - padding: 0 5px; - background-color: var(--barBackgroundColor); -} - -.source-line-scm-inner { - max-width: 40px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.source-line-scm-inner:before { - content: attr(data-author); -} - -.source-line-bar { - width: 5px; - height: 18px; -} - -.source-line-bar[role='button'] { - cursor: pointer; -} - -.source-line-bar:focus { - outline: none; -} - -.source-line-covered { - background-color: var(--lineCoverageGreen) !important; -} - -.source-line-uncovered { - background-color: var(--lineCoverageRed) !important; -} - -.source-line-partially-covered { - background-color: var(--lineCoverageRed) !important; - background-image: repeating-linear-gradient( - 45deg, - rgba(255, 255, 255, 0.5) 4px, - transparent 4px, - transparent 8px, - rgba(255, 255, 255, 0.5) 8px, - rgba(255, 255, 255, 0.5) 12px, - transparent 12px, - transparent 16px, - rgba(255, 255, 255, 0.5) 16px, - rgba(255, 255, 255, 0.5) 20px - ) !important; -} - -.source-line-duplicated { - background-color: #797979 !important; -} - .source-viewer-header { position: relative; padding: 2px 10px 4px; diff --git a/server/sonar-web/src/main/js/components/common/LocationIndex.css b/server/sonar-web/src/main/js/components/common/LocationIndex.css index fc4443ce34b..9d1cf112bae 100644 --- a/server/sonar-web/src/main/js/components/common/LocationIndex.css +++ b/server/sonar-web/src/main/js/components/common/LocationIndex.css @@ -34,7 +34,7 @@ } .location-index.selected { - background-color: #bc5e5e; + background-color: #8f3030; } .location-index.muted { diff --git a/server/sonar-web/src/main/js/components/common/LocationMessage.css b/server/sonar-web/src/main/js/components/common/LocationMessage.css index 29b666d2fdb..cd410c20491 100644 --- a/server/sonar-web/src/main/js/components/common/LocationMessage.css +++ b/server/sonar-web/src/main/js/components/common/LocationMessage.css @@ -41,11 +41,16 @@ } .location-index > .location-message { + display: none; position: absolute; bottom: calc(100% + 4px); left: 0; } +.location-index:hover > .location-message { + display: block; +} + .location-index > .location-message::after { position: absolute; bottom: -5px; diff --git a/server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx new file mode 100644 index 00000000000..e99d2ba414f --- /dev/null +++ b/server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import Icon, { IconProps } from './Icon'; + +export default function ExpandSnippetIcon({ className, fill = 'currentColor', size }: IconProps) { + return ( + + + + + + + + + + + + + ); +} diff --git a/server/sonar-web/src/main/js/helpers/testMocks.ts b/server/sonar-web/src/main/js/helpers/testMocks.ts index cc30abeb85e..9ec9ef6f294 100644 --- a/server/sonar-web/src/main/js/helpers/testMocks.ts +++ b/server/sonar-web/src/main/js/helpers/testMocks.ts @@ -155,6 +155,38 @@ export function mockQualityGateStatusCondition( }; } +export function mockSnippetsByComponent( + component = 'main.js', + lines: number[] = [16] +): T.SnippetsByComponent { + const sources = lines.reduce((lines: { [key: number]: T.SourceLine }, line) => { + lines[line] = mockSourceLine({ line }); + return lines; + }, {}); + return { + component: mockSourceViewerFile({ + key: component, + path: component + }), + sources + }; +} + +export function mockSourceLine(overrides: Partial = {}): T.SourceLine { + return { + line: 16, + code: 'import java.util.ArrayList;', + coverageStatus: 'covered', + coveredConditions: 2, + scmRevision: '80f564becc0c0a1c9abaa006eca83a4fd278c3f0', + scmAuthor: 'simon.brandhof@sonarsource.com', + scmDate: '2018-12-11T10:48:39+0100', + duplicated: false, + isNew: true, + ...overrides + }; +} + export function mockCurrentUser(overrides: Partial = {}): T.CurrentUser { return { isLoggedIn: false, @@ -472,16 +504,6 @@ export function mockStore(state: any = {}, reducer = (state: any) => state): Sto return createStore(reducer, state); } -export function mockSourceLine(overrides: Partial = {}): T.SourceLine { - return { - code: 'function fooBar() {', - coverageStatus: 'covered', - coveredConditions: 2, - line: 5, - ...overrides - }; -} - export function mockDocumentationEntry( overrides: Partial = {} ): DocumentationEntry { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index d2839e1f4b5..6e84ffd3a43 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2243,6 +2243,8 @@ source_viewer.tooltip.no_information_about_tests=There is no extra information a source_viewer.load_more_code=Load More Code source_viewer.loading_more_code=Loading More Code... +source_viewer.expand_above=Expand above +source_viewer.expand_below=Expand below #------------------------------------------------------------------------------ # -- 2.39.5