Browse Source

SONAR-11898 New codeviewer for multi-location issues (#1466)

Also includes SONAR-11901:
Add slim header for the issues page
tags/7.8
Jeremy 5 years ago
parent
commit
6c37e7a9c7
37 changed files with 2139 additions and 488 deletions
  1. 19
    0
      server/sonar-web/src/main/js/api/issues.ts
  2. 1
    3
      server/sonar-web/src/main/js/app/styles/components/component-name.css
  3. 4
    0
      server/sonar-web/src/main/js/app/styles/init/misc.css
  4. 19
    0
      server/sonar-web/src/main/js/app/types.d.ts
  5. 30
    6
      server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts
  6. 10
    10
      server/sonar-web/src/main/js/apps/issues/actions.ts
  7. 39
    36
      server/sonar-web/src/main/js/apps/issues/components/App.tsx
  8. 65
    42
      server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
  9. 361
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx
  10. 26
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
  11. 138
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx
  12. 98
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx
  13. 73
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx
  14. 36
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap
  15. 9
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap
  16. 193
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts
  17. 185
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
  18. 39
    0
      server/sonar-web/src/main/js/apps/issues/styles.css
  19. 22
    12
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx
  20. 18
    50
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
  21. 1
    1
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx
  22. 30
    0
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css
  23. 92
    0
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx
  24. 257
    0
      server/sonar-web/src/main/js/components/SourceViewer/components/Line.css
  25. 14
    9
      server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
  26. 26
    10
      server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx
  27. 0
    2
      server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx
  28. 165
    61
      server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap
  29. 12
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts
  30. 22
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx
  31. 47
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts
  32. 0
    235
      server/sonar-web/src/main/js/components/SourceViewer/styles.css
  33. 1
    1
      server/sonar-web/src/main/js/components/common/LocationIndex.css
  34. 5
    0
      server/sonar-web/src/main/js/components/common/LocationMessage.css
  35. 48
    0
      server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx
  36. 32
    10
      server/sonar-web/src/main/js/helpers/testMocks.ts
  37. 2
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 19
- 0
server/sonar-web/src/main/js/api/issues.ts View File

@@ -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<string[]> {
return getJSON('/api/issues/authors', data).then(r => r.authors, throwGlobalError);
}

export function getIssueFlowSnippets(issueKey: string): Promise<T.Dict<T.SnippetsByComponent>> {
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<T.SourceLine>, line: T.SourceLine) => {
line.coverageStatus = getCoverageStatus(line);
lineMap[line.line] = line;
return lineMap;
},
{}
);
}
});
return result;
}, throwGlobalError);
}

+ 1
- 3
server/sonar-web/src/main/js/app/styles/components/component-name.css View File

@@ -52,8 +52,6 @@
}

.component-name-favorite {
position: relative;
top: -1px;
margin-left: 4px;
padding: 2px 0;
padding: 0;
}

+ 4
- 0
server/sonar-web/src/main/js/app/styles/init/misc.css View File

@@ -57,6 +57,10 @@ th.nowrap {
font-size: var(--smallFontSize);
}

.nudged-up {
margin-top: -1px;
}

.spacer-left {
margin-left: 8px !important;
}

+ 19
- 0
server/sonar-web/src/main/js/app/types.d.ts View File

@@ -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;

+ 30
- 6
server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts View File

@@ -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();
});
});

+ 10
- 10
server/sonar-web/src/main/js/apps/issues/actions.ts View File

@@ -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<State, 'selectedLocationIndex' | 'openIssue'>) => {
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<State, 'selectedFlowIndex' | 'selectedLocationIndex' | 'openIssue'>
) {
const { selectedFlowIndex, selectedLocationIndex: index, openIssue } = state;
if (openIssue) {
const locations =

+ 39
- 36
server/sonar-web/src/main/js/apps/issues/components/App.tsx View File

@@ -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<Props, State> {
);
};

selectLocation = (index?: number) => {
selectLocation = (index: number) => {
this.setState(actions.selectLocation(index));
};

@@ -1036,13 +1035,49 @@ export class App extends React.PureComponent<Props, State> {
);
}

renderHeader({
openIssue,
paging,
selectedIndex
}: {
openIssue: T.Issue | undefined;
paging: T.Paging | undefined;
selectedIndex: number | undefined;
}) {
return openIssue ? (
<A11ySkipTarget anchor="issues_main" />
) : (
<div className="layout-page-header-panel layout-page-main-header issues-main-header">
<div className="layout-page-header-panel-inner layout-page-main-header-inner">
<div className="layout-page-main-inner">
<A11ySkipTarget anchor="issues_main" />

{this.renderBulkChange(openIssue)}
<PageActions
canSetHome={Boolean(
!this.props.organization &&
!this.props.component &&
(!isSonarCloud() || this.props.myIssues)
)}
effortTotal={this.state.effortTotal}
onReload={this.handleReload}
paging={paging}
selectedIndex={selectedIndex}
/>
</div>
</div>
</div>
);
}

renderPage() {
const { checkAll, loading, openIssue, paging } = this.state;
const { checkAll, issues, loading, openIssue, paging } = this.state;
return (
<div className="layout-page-main-inner">
{openIssue ? (
<IssuesSourceViewer
branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
issues={issues}
loadIssues={this.fetchIssuesForComponent}
locationsNavigator={this.state.locationsNavigator}
onIssueChange={this.handleIssueChange}
@@ -1071,7 +1106,6 @@ export class App extends React.PureComponent<Props, State> {
}

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<Props, State> {
{this.renderSide(openIssue)}

<div className="layout-page-main">
<div className="layout-page-header-panel layout-page-main-header issues-main-header">
<div className="layout-page-header-panel-inner layout-page-main-header-inner">
<div className="layout-page-main-inner">
<A11ySkipTarget anchor="issues_main" />

{this.renderBulkChange(openIssue)}
{openIssue ? (
<div className="pull-left width-60">
<ComponentBreadcrumbs
component={component}
issue={openIssue}
organization={this.props.organization}
selectedFlowIndex={this.state.selectedFlowIndex}
selectedLocationIndex={this.state.selectedLocationIndex}
/>
</div>
) : (
<PageActions
canSetHome={Boolean(
!this.props.organization &&
!this.props.component &&
(!isSonarCloud() || this.props.myIssues)
)}
effortTotal={this.state.effortTotal}
onReload={this.handleReload}
paging={paging}
selectedIndex={selectedIndex}
/>
)}
</div>
</div>
</div>
{this.renderHeader({ openIssue, paging, selectedIndex })}

{this.renderPage()}
</div>

+ 65
- 42
server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx View File

@@ -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<T.Issue[]>;
locationsNavigator: boolean;
onIssueChange: (issue: T.Issue) => void;
@@ -72,57 +75,77 @@ export default class IssuesSourceViewer extends React.PureComponent<Props> {
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 (
<div ref={node => (this.node = node)}>
<SourceViewer
aroundLine={aroundLine}
branchLike={this.props.branchLike}
component={component}
displayAllIssues={true}
displayLocationMarkers={!allMessagesEmpty}
highlightedLocationMessage={highlightedLocationMessage}
highlightedLocations={highlightedLocations}
loadIssues={loadIssues}
onIssueChange={this.props.onIssueChange}
onIssueSelect={this.props.onIssueSelect}
onLoaded={this.handleLoaded}
onLocationSelect={this.props.onLocationSelect}
scroll={this.handleScroll}
selectedIssue={selectedIssue}
/>
</div>
);
if (locations.length > 1) {
const components = uniq(locations.map(l => l.component));
return (
<div ref={node => (this.node = node)}>
<CrossComponentSourceViewer
branchLike={this.props.branchLike}
components={components}
highlightedLocationMessage={highlightedLocationMessage}
issue={openIssue}
issues={this.props.issues}
locations={locations}
onIssueChange={this.props.onIssueChange}
onLoaded={this.handleLoaded}
onLocationSelect={this.props.onLocationSelect}
scroll={this.handleScroll}
selectedFlowIndex={selectedFlowIndex}
/>
</div>
);
} 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 (
<div ref={node => (this.node = node)}>
<SourceViewer
aroundLine={aroundLine}
branchLike={this.props.branchLike}
component={component}
displayAllIssues={true}
displayLocationMarkers={false}
highlightedLocationMessage={highlightedLocationMessage}
highlightedLocations={highlightedLocations}
loadIssues={loadIssues}
onIssueChange={this.props.onIssueChange}
onIssueSelect={this.props.onIssueSelect}
onLoaded={this.handleLoaded}
onLocationSelect={this.props.onLocationSelect}
scroll={this.handleScroll}
selectedIssue={selectedIssue}
slimHeader={true}
/>
</div>
);
}
}
}

+ 361
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx View File

@@ -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<boolean>;
snippets: T.SourceLine[][];
}

export default class ComponentSourceSnippetViewer extends React.PureComponent<Props, State> {
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<T.SourceLine>, 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 (
<Line
branchLike={undefined}
displayAllIssues={false}
displayCoverage={true}
displayDuplications={false}
displayIssues={!isSinkLine || issuesForLine.length > 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 (
<div className="source-viewer-code snippet" key={index}>
{snippet[0].line > 1 && (
<button
aria-label={translate('source_viewer.expand_above')}
className="expand-block expand-block-above"
onClick={expandBlock('up')}
type="button">
<ExpandSnippetIcon />
</button>
)}
<table className="source-table">
<tbody>
{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
})
)}
</tbody>
</table>
{(!lastLine || snippet[snippet.length - 1].line < lastLine) && (
<button
aria-label={translate('source_viewer.expand_below')}
className="expand-block expand-block-below"
onClick={expandBlock('down')}
type="button">
<ExpandSnippetIcon />
</button>
)}
</div>
);
}

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 (
<div className="component-source-container">
<SourceViewerHeaderSlim
branchLike={branchLike}
expandable={!fullyShown}
loading={loading}
onExpand={this.expandComponent}
sourceViewerFile={snippetGroup.component}
/>
{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
})
)}
</div>
);
}
}

+ 26
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx View File

@@ -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;

+ 138
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx View File

@@ -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<T.SnippetsByComponent>;
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<Props, State> {
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 (
<div>
<DeferredSpinner />
</div>
);
}

const issuesByComponent = issuesByComponentAndLine(this.props.issues);
const locationsByComponent = groupLocationsByComponent(this.props.locations, components);

return (
<div>
{locationsByComponent.map((g, i) => (
<ComponentSourceSnippetViewer
branchLike={this.props.branchLike}
highlightedLocationMessage={this.props.highlightedLocationMessage}
issue={this.props.issue}
issuePopup={this.state.issuePopup}
issuesByLine={issuesByComponent[g.component.key] || {}}
key={this.props.issue.key + '-' + this.props.selectedFlowIndex + '-' + i}
last={i === locationsByComponent.length - 1}
locations={g.locations || []}
onIssueChange={this.props.onIssueChange}
onIssuePopupToggle={this.handleIssuePopupToggle}
onLocationSelect={this.props.onLocationSelect}
renderDuplicationPopup={this.props.renderDuplicationPopup}
scroll={this.props.scroll}
snippetGroup={g}
/>
))}
</div>
);
}
}

+ 98
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx View File

@@ -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<ComponentSourceSnippetViewer['props']> = {}) {
const snippetGroup: T.SnippetGroup = {
component: mockSourceViewerFile(),
locations: [],
sources: []
};
return shallow<ComponentSourceSnippetViewer>(
<ComponentSourceSnippetViewer
branchLike={mockMainBranch()}
highlightedLocationMessage={{ index: 0, text: '' }}
issue={mockIssue()}
issuesByLine={{}}
last={false}
locations={[]}
onIssueChange={jest.fn()}
onIssuePopupToggle={jest.fn()}
onLocationSelect={jest.fn()}
renderDuplicationPopup={jest.fn()}
scroll={jest.fn()}
snippetGroup={snippetGroup}
{...props}
/>
);
}

+ 73
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx View File

@@ -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<CrossComponentSourceViewerWrapper['props']> = {}) {
return shallow<CrossComponentSourceViewerWrapper>(
<CrossComponentSourceViewerWrapper
branchLike={undefined}
highlightedLocationMessage={undefined}
issue={mockIssue(true)}
issues={[]}
locations={[]}
onIssueChange={jest.fn()}
onLoaded={jest.fn()}
onLocationSelect={jest.fn()}
renderDuplicationPopup={jest.fn()}
scroll={jest.fn()}
selectedFlowIndex={0}
{...props}
/>
);
}

+ 36
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap View File

@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<div
className="component-source-container"
>
<SourceViewerHeaderSlim
branchLike={
Object {
"analysisDate": "2018-01-01",
"isMain": true,
"name": "master",
}
}
expandable={true}
loading={false}
onExpand={[Function]}
sourceViewerFile={
Object {
"key": "foo",
"measures": Object {
"coverage": "85.2",
"duplicationDensity": "1.0",
"issues": "12",
"lines": "56",
},
"path": "foo/bar.ts",
"project": "my-project",
"projectName": "MyProject",
"q": "FIL",
"uuid": "foo-bar",
}
}
/>
</div>
`;

+ 9
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap View File

@@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render correctly 1`] = `
<div>
<DeferredSpinner
timeout={100}
/>
</div>
`;

+ 193
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts View File

@@ -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));
});
});

+ 185
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts View File

@@ -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;
}

+ 39
- 0
server/sonar-web/src/main/js/apps/issues/styles.css View File

@@ -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;

+ 22
- 12
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx View File

@@ -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<Props, State>
);
}

renderHeader(branchLike: T.BranchLike | undefined, sourceViewerFile: T.SourceViewerFile) {
return this.props.slimHeader ? (
<SourceViewerHeaderSlim branchLike={branchLike} sourceViewerFile={sourceViewerFile} />
) : (
<WorkspaceContext.Consumer>
{({ openComponent }) => (
<SourceViewerHeader
branchLike={this.props.branchLike}
issues={this.state.issues}
openComponent={openComponent}
showMeasures={this.props.showMeasures}
sourceViewerFile={sourceViewerFile}
/>
)}
</WorkspaceContext.Consumer>
);
}

render() {
const { component, loading, sources, notAccessible, sourceRemoved } = this.state;

@@ -701,17 +721,7 @@ export default class SourceViewerBase extends React.PureComponent<Props, State>
return (
<SourceViewerContext.Provider value={{ branchLike: this.props.branchLike, file: component }}>
<div className={className} ref={node => (this.node = node)}>
<WorkspaceContext.Consumer>
{({ openComponent }) => (
<SourceViewerHeader
branchLike={this.props.branchLike}
issues={this.state.issues}
openComponent={openComponent}
showMeasures={this.props.showMeasures}
sourceViewerFile={component}
/>
)}
</WorkspaceContext.Consumer>
{this.renderHeader(this.props.branchLike, component)}
{sourceRemoved && (
<Alert className="spacer-top" variant="warning">
{translate('code_viewer.no_source_code_displayed_due_to_source_removed')}

+ 18
- 50
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx View File

@@ -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<Props> {
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<Props> {
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 (
<Line
branchLike={this.props.branchLike}
@@ -162,8 +124,14 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
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<Props> {
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)}
/>
);
};

+ 1
- 1
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx View File

@@ -97,7 +97,7 @@ export default class SourceViewerHeader extends React.PureComponent<Props, State
</a>
</div>

{subProject != null && (
{subProject !== undefined && (
<div className="component-name-parent">
<QualifierIcon qualifier="BRC" /> <span>{subProjectName}</span>
</div>

+ 30
- 0
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css View File

@@ -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));
}

+ 92
- 0
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx View File

@@ -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 (
<div className="source-viewer-header-slim display-flex-row display-flex-space-between">
<div className="display-flex-row flex-1">
<div>
<a
className="link-with-icon"
href={getPathUrlAsString(getBranchLikeUrl(project, branchLike))}>
<QualifierIcon qualifier="TRK" /> <span>{projectName}</span>
</a>
</div>

{subProject !== undefined && (
<div className="">
<QualifierIcon qualifier="BRC" /> <span>{subProjectName}</span>
</div>
)}

<div className="spacer-left">
<QualifierIcon qualifier={q} /> <span>{collapsedDirFromPath(path)}</span>
<span className="component-name-file">{fileFromPath(path)}</span>
</div>
{sourceViewerFile.canMarkAsFavorite && (!branchLike || isMainBranch(branchLike)) && (
<div className="nudged-up">
<Favorite
className="component-name-favorite"
component={key}
favorite={sourceViewerFile.fav || false}
qualifier={sourceViewerFile.q}
/>
</div>
)}
</div>

{expandable && (
<DeferredSpinner className="little-spacer-right" loading={loading}>
<div className="source-viewer-header-slim-actions flex-0">
<ButtonIcon className="js-actions" onClick={onExpand}>
<ExpandSnippetIcon />
</ButtonIcon>
</div>
</DeferredSpinner>
)}
</div>
);
}

+ 257
- 0
server/sonar-web/src/main/js/components/SourceViewer/components/Line.css View File

@@ -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;
}

+ 14
- 9
server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx View File

@@ -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<Props> {
isPopupOpen = (name: string, index?: number) => {
const { line, linePopup } = this.props;
@@ -103,9 +101,13 @@ export default class Line extends React.PureComponent<Props> {
'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 (
<tr className={className} data-line-number={line.line}>
<LineNumber
@@ -121,12 +123,14 @@ export default class Line extends React.PureComponent<Props> {
previousLine={this.props.previousLine}
/>

{this.props.displayIssues && !this.props.displayAllIssues && (
{this.props.displayIssues && !this.props.displayAllIssues ? (
<LineIssuesIndicator
issues={this.props.issues}
line={line}
onClick={this.handleIssuesIndicatorClick}
/>
) : (
<td className="source-meta source-line-issues" />
)}

{this.props.displayDuplications && (
@@ -161,6 +165,7 @@ export default class Line extends React.PureComponent<Props> {
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}

+ 26
- 10
server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx View File

@@ -43,14 +43,9 @@ interface Props {
onIssueSelect: (issueKey: string) => void;
onLocationSelect: ((index: number) => void) | undefined;
onSymbolClick: (symbols: Array<string>) => 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<Props, State> {
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<Props, State> {
issueLocations,
line,
onIssueSelect,
padding,
secondaryIssueLocations,
selectedIssue,
showIssues
@@ -204,7 +202,8 @@ export default class LineCode extends React.PureComponent<Props, State> {
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<Props, State> {
leadingMarker = (index === 0 ? true : leadingMarker) && !token.text.trim().length;
});

const style = padding
? {
paddingBottom: padding + 'px'
}
: undefined;

return (
<td className={className} data-line-number={line.line}>
<td className={className} data-line-number={line.line} style={style}>
<div className="source-line-code-inner">
<pre ref={node => (this.codeNode = node)}>{renderedTokens}</pre>
</div>
@@ -234,6 +239,17 @@ export default class LineCode extends React.PureComponent<Props, State> {
selectedIssue={selectedIssue}
/>
)}
{selectedIssue && !showIssues && (
<LineIssuesList
branchLike={this.props.branchLike}
issuePopup={this.props.issuePopup}
issues={issues.filter(i => i.key === selectedIssue)}
onIssueChange={this.props.onIssueChange}
onIssueClick={onIssueSelect}
onIssuePopupToggle={this.props.onIssuePopupToggle}
selectedIssue={selectedIssue}
/>
)}
</td>
);
}

+ 0
- 2
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx View File

@@ -92,8 +92,6 @@ function shallowRender(props: Partial<Line['props']> = {}) {
displayAllIssues={false}
displayCoverage={false}
displayDuplications={false}
displayIssueLocationsCount={false}
displayIssueLocationsLink={false}
displayIssues={false}
displayLocationMarkers={false}
duplications={[]}

+ 165
- 61
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap View File

@@ -2,16 +2,21 @@

exports[`should render correctly 1`] = `
<tr
className="source-line"
data-line-number={5}
className="source-line source-line-filtered"
data-line-number={16}
>
<LineNumber
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<LineSCM
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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}
/>
<td
className="source-meta source-line-issues"
/>
<LineCode
branchLike={
Object {
@@ -40,8 +53,6 @@ exports[`should render correctly 1`] = `
"title": "Foo Bar feature",
}
}
displayIssueLocationsCount={false}
displayIssueLocationsLink={false}
displayLocationMarkers={false}
issueLocations={Array []}
issues={
@@ -112,10 +123,15 @@ exports[`should render correctly 1`] = `
}
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<tr
className="source-line source-line-highlighted source-line-filtered source-line-last"
data-line-number={5}
data-line-number={16}
>
<LineNumber
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<LineSCM
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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}
/>
<td
className="source-meta source-line-issues"
/>
<LineCode
branchLike={
Object {
@@ -172,8 +199,6 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = `
"title": "Foo Bar feature",
}
}
displayIssueLocationsCount={false}
displayIssueLocationsLink={false}
displayLocationMarkers={false}
issueLocations={Array []}
issues={
@@ -244,11 +269,15 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = `
}
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<tr
className="source-line"
data-line-number={5}
className="source-line source-line-filtered"
data-line-number={16}
>
<LineNumber
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<LineSCM
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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}
/>
<td
className="source-meta source-line-issues"
/>
<LineCoverage
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<tr
className="source-line"
data-line-number={5}
className="source-line source-line-filtered"
data-line-number={16}
>
<LineNumber
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<LineSCM
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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}
/>
<td
className="source-meta source-line-issues"
/>
<LineDuplications
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<tr
className="source-line"
data-line-number={5}
className="source-line source-line-filtered"
data-line-number={16}
>
<LineNumber
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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`] = `
<LineSCM
line={
Object {
"code": "function fooBar() {",
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"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]}

+ 12
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts View File

@@ -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 => {

+ 22
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx View File

@@ -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];
}, []);
}

+ 47
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts View File

@@ -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;
}

+ 0
- 235
server/sonar-web/src/main/js/components/SourceViewer/styles.css View File

@@ -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;

+ 1
- 1
server/sonar-web/src/main/js/components/common/LocationIndex.css View File

@@ -34,7 +34,7 @@
}

.location-index.selected {
background-color: #bc5e5e;
background-color: #8f3030;
}

.location-index.muted {

+ 5
- 0
server/sonar-web/src/main/js/components/common/LocationMessage.css View File

@@ -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;

+ 48
- 0
server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx View File

@@ -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 (
<Icon className={className} size={size}>
<g fill="none" fillRule="evenodd">
<path
d="M8 1v4H4"
stroke={fill}
strokeWidth="2"
transform="scale(-.83333 -.84583) rotate(45 7.66 -19.75)"
/>
<path d="M3 5.78h10v1.7H3z" fill={fill} />
<path d="M7.17 2.4h1.66v5.07H7.17z" fill={fill} />
<g>
<path
d="M8.16 1.81V6.1H3.9"
stroke={fill}
strokeWidth="2"
transform="scale(.83333 .84583) rotate(45 -4.2 13.2)"
/>
<path d="M13 10.01H3v-1.7h10z" fill={fill} />
<path d="M8.83 13.4H7.17V9.15h1.66z" fill={fill} />
</g>
</g>
</Icon>
);
}

+ 32
- 10
server/sonar-web/src/main/js/helpers/testMocks.ts View File

@@ -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> = {}): T.SourceLine {
return {
line: 16,
code: '<span class="k">import</span> java.util.<span class="sym-9 sym">ArrayList</span>;',
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> = {}): 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> = {}): T.SourceLine {
return {
code: 'function fooBar() {',
coverageStatus: 'covered',
coveredConditions: 2,
line: 5,
...overrides
};
}

export function mockDocumentationEntry(
overrides: Partial<DocumentationEntry> = {}
): DocumentationEntry {

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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

#------------------------------------------------------------------------------
#

Loading…
Cancel
Save