diff options
Diffstat (limited to 'server/sonar-web/src')
18 files changed, 799 insertions, 133 deletions
diff --git a/server/sonar-web/src/main/js/apps/issues/controller.js b/server/sonar-web/src/main/js/apps/issues/controller.js index dd03639f5b0..156a7f3632a 100644 --- a/server/sonar-web/src/main/js/apps/issues/controller.js +++ b/server/sonar-web/src/main/js/apps/issues/controller.js @@ -63,8 +63,10 @@ export default Controller.extend({ const issues = that.options.app.list.parseIssues(r); this.receiveIssues(issues); if (firstPage) { + const issues = that.options.app.list.parseIssues(r); that.options.app.list.reset(issues); } else { + const issues = that.options.app.list.parseIssues(r, that.options.app.list.length); that.options.app.list.add(issues); } that.options.app.list.setIndex(); diff --git a/server/sonar-web/src/main/js/apps/issues/models/issues.js b/server/sonar-web/src/main/js/apps/issues/models/issues.js index 6106c81404f..d7340187c0a 100644 --- a/server/sonar-web/src/main/js/apps/issues/models/issues.js +++ b/server/sonar-web/src/main/js/apps/issues/models/issues.js @@ -76,10 +76,10 @@ export default Backbone.Collection.extend({ return issue; }, - parseIssues (r) { + parseIssues (r, startIndex = 0) { const that = this; return r.issues.map((issue, index) => { - Object.assign(issue, { index }); + Object.assign(issue, { index: startIndex + index }); issue = that._injectRelational(issue, r.components, 'component', 'key'); issue = that._injectRelational(issue, r.components, 'project', 'key'); issue = that._injectRelational(issue, r.components, 'subProject', 'key'); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js index 2ad750e7120..4a011e163c3 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js @@ -23,12 +23,12 @@ import classNames from 'classnames'; import uniqBy from 'lodash/uniqBy'; import SourceViewerHeader from './SourceViewerHeader'; import SourceViewerCode from './SourceViewerCode'; +import SourceViewerIssueLocations from './SourceViewerIssueLocations'; import CoveragePopupView from '../source-viewer/popups/coverage-popup'; import DuplicationPopupView from '../source-viewer/popups/duplication-popup'; import LineActionsPopupView from '../source-viewer/popups/line-actions-popup'; import SCMPopupView from '../source-viewer/popups/scm-popup'; import MeasuresOverlay from '../source-viewer/measures-overlay'; -import { TooltipsContainer } from '../mixins/tooltips-mixin'; import Source from '../source-viewer/source'; import loadIssues from './helpers/loadIssues'; import getCoverageStatus from './helpers/getCoverageStatus'; @@ -38,12 +38,21 @@ import { locationsByIssueAndLine, locationMessagesByIssueAndLine, duplicationsByLine, - symbolsByLine + symbolsByLine, + findLocationByIndex +} from './helpers/indexing'; +import type { + LinearIssueLocation, + IndexedIssueLocation, + IndexedIssueLocationsByIssueAndLine, + IndexedIssueLocationMessagesByIssueAndLine } from './helpers/indexing'; import { getComponentForSourceViewer, getSources, getDuplications, getTests } from '../../api/components'; import { translate } from '../../helpers/l10n'; +import { scrollToElement } from '../../helpers/scrolling'; import type { SourceLine } from './types'; import type { Issue } from '../issue/types'; +import './styles.css'; // TODO react-virtualized @@ -81,28 +90,25 @@ type State = { highlightedSymbol: string | null, issues?: Array<Issue>, issuesByLine: { [number]: Array<string> }, - issueLocationsByLine: { [number]: Array<{ from: number, to: number }> }, - issueSecondaryLocationsByIssueByLine: { - [string]: { - [number]: Array<{ from: number, to: number }> - } - }, - issueSecondaryLocationMessagesByIssueByLine: { - [issueKey: string]: { - [line: number]: Array<{ msg: string, index?: number }> - } - }, + issueLocationsByLine: { [number]: Array<LinearIssueLocation> }, + issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine, + issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine, loading: boolean, loadingSourcesAfter: boolean, loadingSourcesBefore: boolean, + locationsPanelHeight: number, notAccessible: boolean, notExist: boolean, + selectedIssueLocation: IndexedIssueLocation | null, sources?: Array<SourceLine>, symbolsByLine: { [number]: Array<string> } }; const LINES = 500; +const LOCATIONS_PANEL_DEFAULT_HEIGHT = 200; +const LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY = 'sonarqube.locations.height'; + const loadComponent = (key: string): Promise<*> => { return getComponentForSourceViewer(key); }; @@ -141,9 +147,11 @@ export default class SourceViewerBase extends React.Component { loading: true, loadingSourcesAfter: false, loadingSourcesBefore: false, + locationsPanelHeight: this.getInitialLocationsPanelHeight(), notAccessible: false, notExist: false, selectedIssue: props.defaultSelectedIssue || null, + selectedIssueLocation: null, symbolsByLine: {} }; } @@ -153,19 +161,33 @@ export default class SourceViewerBase extends React.Component { this.fetchComponent(); } - componentDidUpdate (prevProps: Props) { + componentDidUpdate (prevProps: Props, prevState: State) { if (prevProps.component !== this.props.component) { this.fetchComponent(); } else if (this.props.aroundLine != null && prevProps.aroundLine !== this.props.aroundLine && this.isLineOutsideOfRange(this.props.aroundLine)) { this.fetchSources(); } + + if (prevState.selectedIssueLocation !== this.state.selectedIssueLocation && + this.state.selectedIssueLocation != null) { + this.scrollToLine(this.state.selectedIssueLocation.line); + } } componentWillUnmount () { this.mounted = false; } + scrollToLine (line: number) { + const lineElement = this.node.querySelector( + `.source-line-code[data-line-number="${line}"] .source-line-issue-locations` + ); + if (lineElement) { + scrollToElement(lineElement, 125, this.state.locationsPanelHeight + 75); + } + } + computeCoverageStatus (lines: Array<SourceLine>): Array<SourceLine> { return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) })); } @@ -342,6 +364,23 @@ export default class SourceViewerBase extends React.Component { }); }; + getInitialLocationsPanelHeight () { + try { + const rawValue = window.localStorage.getItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY); + if (!rawValue) { + return LOCATIONS_PANEL_DEFAULT_HEIGHT; + } + const intValue = Number(rawValue); + return !isNaN(intValue) ? intValue : LOCATIONS_PANEL_DEFAULT_HEIGHT; + } catch (e) { + return LOCATIONS_PANEL_DEFAULT_HEIGHT; + } + } + + storeLocationsPanelHeight (height: number) { + window.localStorage.setItem(LOCATIONS_PANEL_HEIGHT_LOCAL_STORAGE_KEY, height); + } + openNewWindow = () => { const { component } = this.state; if (component != null) { @@ -424,41 +463,57 @@ export default class SourceViewerBase extends React.Component { popup.render(); }; + handleSelectIssueLocation = (flowIndex: number, locationIndex: number) => { + this.setState(prevState => { + const selectedIssueLocation = findLocationByIndex( + prevState.issueSecondaryLocationsByIssueByLine, + flowIndex, + locationIndex + ); + return { selectedIssueLocation }; + }); + }; + + handleLocationsPanelResize = (height: number) => { + this.setState({ locationsPanelHeight: height }); + this.storeLocationsPanelHeight(height); + }; + renderCode (sources: Array<SourceLine>) { const hasSourcesBefore = sources.length > 0 && sources[0].line > 1; return ( - <TooltipsContainer> - <SourceViewerCode - displayAllIssues={this.props.displayAllIssues} - duplications={this.state.duplications} - duplicationsByLine={this.state.duplicationsByLine} - duplicatedFiles={this.state.duplicatedFiles} - hasSourcesBefore={hasSourcesBefore} - hasSourcesAfter={this.state.hasSourcesAfter} - filterLine={this.props.filterLine} - highlightedLine={this.state.highlightedLine} - highlightedSymbol={this.state.highlightedSymbol} - issues={this.state.issues} - issuesByLine={this.state.issuesByLine} - issueLocationsByLine={this.state.issueLocationsByLine} - issueSecondaryLocationsByIssueByLine={this.state.issueSecondaryLocationsByIssueByLine} - issueSecondaryLocationMessagesByIssueByLine={this.state.issueSecondaryLocationMessagesByIssueByLine} - loadDuplications={this.loadDuplications} - loadSourcesAfter={this.loadSourcesAfter} - loadSourcesBefore={this.loadSourcesBefore} - loadingSourcesAfter={this.state.loadingSourcesAfter} - loadingSourcesBefore={this.state.loadingSourcesBefore} - onCoverageClick={this.handleCoverageClick} - onDuplicationClick={this.handleDuplicationClick} - onIssueSelect={this.props.onIssueSelect} - onIssueUnselect={this.props.onIssueUnselect} - onLineClick={this.handleLineClick} - onSCMClick={this.handleSCMClick} - onSymbolClick={this.handleSymbolClick} - selectedIssue={this.props.selectedIssue} - sources={sources} - symbolsByLine={this.state.symbolsByLine}/> - </TooltipsContainer> + <SourceViewerCode + displayAllIssues={this.props.displayAllIssues} + duplications={this.state.duplications} + duplicationsByLine={this.state.duplicationsByLine} + duplicatedFiles={this.state.duplicatedFiles} + hasSourcesBefore={hasSourcesBefore} + hasSourcesAfter={this.state.hasSourcesAfter} + filterLine={this.props.filterLine} + highlightedLine={this.state.highlightedLine} + highlightedSymbol={this.state.highlightedSymbol} + issues={this.state.issues} + issuesByLine={this.state.issuesByLine} + issueLocationsByLine={this.state.issueLocationsByLine} + issueSecondaryLocationsByIssueByLine={this.state.issueSecondaryLocationsByIssueByLine} + issueSecondaryLocationMessagesByIssueByLine={this.state.issueSecondaryLocationMessagesByIssueByLine} + loadDuplications={this.loadDuplications} + loadSourcesAfter={this.loadSourcesAfter} + loadSourcesBefore={this.loadSourcesBefore} + loadingSourcesAfter={this.state.loadingSourcesAfter} + loadingSourcesBefore={this.state.loadingSourcesBefore} + onCoverageClick={this.handleCoverageClick} + onDuplicationClick={this.handleDuplicationClick} + onIssueSelect={this.props.onIssueSelect} + onIssueUnselect={this.props.onIssueUnselect} + onLineClick={this.handleLineClick} + onSCMClick={this.handleSCMClick} + onSelectLocation={this.handleSelectIssueLocation} + onSymbolClick={this.handleSymbolClick} + selectedIssue={this.props.selectedIssue} + selectedIssueLocation={this.state.selectedIssueLocation} + sources={sources} + symbolsByLine={this.state.symbolsByLine}/> ); } @@ -481,6 +536,10 @@ export default class SourceViewerBase extends React.Component { const className = classNames('source-viewer', { 'source-duplications-expanded': this.state.displayDuplications }); + const selectedIssueObj = this.props.selectedIssue && this.state.issues != null ? + this.state.issues.find(issue => issue.key === this.props.selectedIssue) : + null; + return ( <div className={className} ref={node => this.node = node}> <SourceViewerHeader @@ -493,6 +552,14 @@ export default class SourceViewerBase extends React.Component { </div> )} {this.state.sources != null && this.renderCode(this.state.sources)} + {selectedIssueObj != null && selectedIssueObj.flows.length > 0 && ( + <SourceViewerIssueLocations + height={this.state.locationsPanelHeight} + issue={selectedIssueObj} + onResize={this.handleLocationsPanelResize} + onSelectLocation={this.handleSelectIssueLocation} + selectedLocation={this.state.selectedIssueLocation}/> + )} </div> ); } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js index 32092dd47c5..fb98c06ab15 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js @@ -20,9 +20,16 @@ // @flow import React from 'react'; import SourceViewerLine from './SourceViewerLine'; +import { TooltipsContainer } from '../mixins/tooltips-mixin'; import { translate } from '../../helpers/l10n'; import type { Duplication, SourceLine } from './types'; import type { Issue } from '../issue/types'; +import type { + LinearIssueLocation, + IndexedIssueLocation, + IndexedIssueLocationsByIssueAndLine, + IndexedIssueLocationMessagesByIssueAndLine +} from './helpers/indexing'; const EMPTY_ARRAY = []; @@ -32,7 +39,7 @@ const ZERO_LINE = { line: 0 }; -export default class SourceViewerCode extends React.Component { +export default class SourceViewerCode extends React.PureComponent { props: { displayAllIssues: boolean, duplications?: Array<Duplication>, @@ -45,17 +52,9 @@ export default class SourceViewerCode extends React.Component { highlightedSymbol: string | null, issues: Array<Issue>, issuesByLine: { [number]: Array<string> }, - issueLocationsByLine: { [number]: Array<{ from: number, to: number }> }, - issueSecondaryLocationsByIssueByLine: { - [string]: { - [number]: Array<{ from: number, to: number }> - } - }, - issueSecondaryLocationMessagesByIssueByLine: { - [issueKey: string]: { - [line: number]: Array<{ msg: string, index?: number }> - } - }, + issueLocationsByLine: { [number]: Array<LinearIssueLocation> }, + issueSecondaryLocationsByIssueByLine: IndexedIssueLocationsByIssueAndLine, + issueSecondaryLocationMessagesByIssueByLine: IndexedIssueLocationMessagesByIssueAndLine, loadDuplications: (SourceLine, HTMLElement) => void, loadSourcesAfter: () => void, loadSourcesBefore: () => void, @@ -67,8 +66,10 @@ export default class SourceViewerCode extends React.Component { onIssueUnselect: () => void, onLineClick: (number, HTMLElement) => void, onSCMClick: (SourceLine, HTMLElement) => void, + onSelectLocation: (flowIndex: number, locationIndex: number) => void, onSymbolClick: (string) => void, selectedIssue: string | null, + selectedIssueLocation: IndexedIssueLocation | null, sources: Array<SourceLine>, symbolsByLine: { [number]: Array<string> } }; @@ -133,6 +134,14 @@ export default class SourceViewerCode extends React.Component { const optimizedSelectedIssue = selectedIssue != null && issuesForLine.includes(selectedIssue) ? selectedIssue : null; + const { selectedIssueLocation } = this.props; + const optimizedSelectedIssueLocation = + selectedIssueLocation != null && + secondaryIssueLocations.some(location => + location.flowIndex === selectedIssueLocation.flowIndex && + location.locationIndex === selectedIssueLocation.locationIndex + ) ? selectedIssueLocation : null; + return ( <SourceViewerLine displayAllIssues={this.props.displayAllIssues} @@ -157,10 +166,12 @@ export default class SourceViewerCode extends React.Component { onIssueSelect={this.props.onIssueSelect} onIssueUnselect={this.props.onIssueUnselect} onSCMClick={this.props.onSCMClick} + onSelectLocation={this.props.onSelectLocation} onSymbolClick={this.props.onSymbolClick} secondaryIssueLocations={secondaryIssueLocations} secondaryIssueLocationMessages={secondaryIssueLocationMessages} - selectedIssue={optimizedSelectedIssue}/> + selectedIssue={optimizedSelectedIssue} + selectedIssueLocation={optimizedSelectedIssueLocation}/> ); }; @@ -191,16 +202,18 @@ export default class SourceViewerCode extends React.Component { </div> )} - <table className="source-table"> - <tbody> - {hasFileIssues && ( - this.renderLine(ZERO_LINE, -1, hasCoverage, hasDuplications, displayFiltered, hasIssues) - )} - {sources.map((line, index) => ( - this.renderLine(line, index, hasCoverage, hasDuplications, displayFiltered, hasIssues) - ))} - </tbody> - </table> + <TooltipsContainer> + <table className="source-table"> + <tbody> + {hasFileIssues && ( + this.renderLine(ZERO_LINE, -1, hasCoverage, hasDuplications, displayFiltered, hasIssues) + )} + {sources.map((line, index) => ( + this.renderLine(line, index, hasCoverage, hasDuplications, displayFiltered, hasIssues) + ))} + </tbody> + </table> + </TooltipsContainer> {this.props.hasSourcesAfter && ( <div className="source-viewer-more-code"> diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js new file mode 100644 index 00000000000..d4881347372 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssueLocations.js @@ -0,0 +1,319 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +// @flow +import React from 'react'; +import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; +import { DraggableCore } from 'react-draggable'; +import classNames from 'classnames'; +import throttle from 'lodash/throttle'; +import { scrollToElement } from '../../helpers/scrolling'; +import { translate } from '../../helpers/l10n'; +import type { Issue, FlowLocation } from '../issue/types'; +import type { IndexedIssueLocation } from './helpers/indexing'; + +type Props = { + height: number, + issue: Issue, + onResize: (height: number) => void, + onSelectLocation: (flowIndex: number, locationIndex: number) => void, + selectedLocation: IndexedIssueLocation | null +}; + +type State = { + fixed: boolean, + locationBlink: boolean +}; + +export default class SourceViewerIssueLocations extends React.Component { + fixedNode: HTMLElement; + locations: { [string]: HTMLElement } = {}; + node: HTMLElement; + props: Props; + rootNode: HTMLElement; + state: State; + + constructor (props: Props) { + super(props); + this.state = { fixed: true, locationBlink: false }; + this.handleScroll = throttle(this.handleScroll, 50); + } + + componentDidMount () { + this.bindShortcuts(); + this.listenScroll(); + } + + componentWillReceiveProps (nextProps: Props) { + /* eslint-disable no-console */ + console.log('foo'); + + if (nextProps.selectedLocation !== this.props.selectedLocation) { + this.setState({ locationBlink: false }); + } + } + + componentDidUpdate (prevProps: Props) { + if ( + prevProps.selectedLocation !== this.props.selectedLocation && + this.props.selectedLocation != null + ) { + this.scrollToLocation(); + } + } + + componentWillUnmount () { + this.unbindShortcuts(); + this.unlistenScroll(); + } + + bindShortcuts () { + document.addEventListener('keydown', this.handleKeyPress); + } + + unbindShortcuts () { + document.removeEventListener('keydown', this.handleKeyPress); + } + + listenScroll () { + window.addEventListener('scroll', this.handleScroll); + } + + unlistenScroll () { + window.removeEventListener('scroll', this.handleScroll); + } + + blinkLocation = () => { + this.setState({ locationBlink: true }); + setTimeout(() => this.setState({ locationBlink: false }), 1000); + }; + + handleScroll = () => { + const rootNodeTop = this.rootNode.getBoundingClientRect().top; + const fixedNodeRect = this.fixedNode.getBoundingClientRect(); + const fixedNodeTop = fixedNodeRect.top; + const fixedNodeBottom = fixedNodeRect.bottom; + this.setState((state: State) => { + if (state.fixed) { + if (rootNodeTop <= fixedNodeTop) { + return { fixed: false }; + } + } else if (fixedNodeBottom >= window.innerHeight) { + return { fixed: true }; + } + }); + }; + + handleDrag = (e: Event, data: { deltaY: number }) => { + let height = this.props.height - data.deltaY; + if (height < 100) { + height = 100; + } + if (height > window.innerHeight / 2) { + height = window.innerHeight / 2; + } + this.props.onResize(height); + }; + + scrollToLocation () { + const { selectedLocation } = this.props; + if (selectedLocation != null) { + const key = `${selectedLocation.flowIndex}-${selectedLocation.locationIndex}`; + const locationElement = this.locations[key]; + if (locationElement) { + scrollToElement(locationElement, 15, 15, this.node); + } + } + } + + handleSelectPrev () { + const { issue, selectedLocation } = this.props; + if (!selectedLocation) { + if (issue.flows.length > 0) { + // move to the first location of the first flow + this.props.onSelectLocation(0, 0); + } + } else { + const currentFlow = issue.flows[selectedLocation.flowIndex]; + if ( + currentFlow.locations != null && + currentFlow.locations.length > selectedLocation.locationIndex + 1 + ) { + // move to the next location for the same flow + this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex + 1); + } else if (selectedLocation.flowIndex > 0) { + // move to the first location of the previous flow + this.props.onSelectLocation(selectedLocation.flowIndex - 1, 0); + } else { + this.blinkLocation(); + } + } + } + + handleSelectNext () { + const { issue, selectedLocation } = this.props; + if (!selectedLocation) { + if (issue.flows.length > 0) { + // move to the last location of the first flow + const firstFlow = issue.flows[0]; + if (firstFlow.locations != null) { + this.props.onSelectLocation(0, firstFlow.locations.length - 1); + } + } + } else if (selectedLocation.locationIndex > 0) { + // move to the previous location for the same flow + this.props.onSelectLocation(selectedLocation.flowIndex, selectedLocation.locationIndex - 1); + } else if (issue.flows.length > selectedLocation.flowIndex + 1) { + // move to the last location of the next flow + const nextFlow = issue.flows[selectedLocation.flowIndex + 1]; + if (nextFlow.locations) { + this.props.onSelectLocation(selectedLocation.flowIndex + 1, nextFlow.locations.length - 1); + } + } else { + this.blinkLocation(); + } + } + + handleKeyPress = (e: Object) => { + const tagName = e.target.tagName.toUpperCase(); + const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON'; + + if (shouldHandle) { + const selectNext = e.keyCode === 40 && e.altKey; + const selectPrev = e.keyCode === 38 && e.altKey; + + if (selectNext) { + e.preventDefault(); + this.handleSelectNext(); + } + + if (selectPrev) { + e.preventDefault(); + this.handleSelectPrev(); + } + } + }; + + reverseLocations (locations: Array<*>) { + return [...locations].reverse(); + } + + isLocationSelected (flowIndex: number, locationIndex: number) { + const { selectedLocation } = this.props; + if (selectedLocation == null) { + return false; + } else { + return selectedLocation.flowIndex === flowIndex && + selectedLocation.locationIndex === locationIndex; + } + } + + handleLocationClick (flowIndex: number, locationIndex: number, e: SyntheticInputEvent) { + e.preventDefault(); + this.props.onSelectLocation(flowIndex, locationIndex); + } + + renderLocation = ( + location: FlowLocation, + flowIndex: number, + locationIndex: number, + locations: Array<*> + ) => { + const displayIndex = locations.length > 1; + const line = location.textRange ? location.textRange.startLine : null; + const key = `${flowIndex}-${locationIndex}`; + // note that locations order is reversed + const selected = this.isLocationSelected(flowIndex, locations.length - locationIndex - 1); + + return ( + <li key={key} ref={node => this.locations[key] = node} className="spacer-bottom"> + {line != null && <code className="source-issue-locations-line">L{line}</code>} + <a + className={classNames('issue-location-message', 'flash', 'flash-heavy', { + selected, + in: selected && this.state.locationBlink + })} + href="#" + onClick={this.handleLocationClick.bind( + this, + flowIndex, + locations.length - locationIndex - 1 + )}> + {displayIndex && <strong>{locationIndex + 1}: </strong>} + {location.msg} + </a> + </li> + ); + }; + + render () { + const { flows } = this.props.issue; + const { height } = this.props; + + const className = classNames('source-issue-locations-panel', { fixed: this.state.fixed }); + + return ( + <AutoSizer disableHeight={true}> + {({ width }) => ( + <div + ref={node => this.rootNode = node} + className="source-issue-locations" + style={{ width, height }}> + <div + ref={node => this.fixedNode = node} + className={className} + style={{ width, height }}> + <header className="source-issue-locations-header"/> + <div className="source-issue-locations-shortcuts"> + <span className="shortcut-button">Alt</span> + {' + '} + <span className="shortcut-button">↑</span> + {' '} + <span className="shortcut-button">↓</span> + {' '} + {translate('source_viewer.to_navigate_issue_locations')} + </div> + <ul + ref={node => this.node = node} + className="source-issue-locations-list" + style={{ height: height - 15 }}> + {flows.map( + (flow, flowIndex) => + flow.locations != null && + this + .reverseLocations(flow.locations) + .map((location, locationIndex) => + this.renderLocation( + location, + flowIndex, + locationIndex, + flow.locations || [] + )) + )} + </ul> + <DraggableCore axis="y" onDrag={this.handleDrag} offsetParent={document.body}> + <div className="workspace-viewer-resize"/> + </DraggableCore> + </div> + </div> + )} + </AutoSizer> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js index 72cb0d5c053..5a53275c7af 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js @@ -26,6 +26,7 @@ import SourceViewerIssuesIndicator from './SourceViewerIssuesIndicator'; import { translate } from '../../helpers/l10n'; import { splitByTokens, highlightSymbol, highlightIssueLocations, generateHTML } from './helpers/highlight'; import type { SourceLine } from './types'; +import type { LinearIssueLocation, IndexedIssueLocation, IndexedIssueLocationMessage } from './helpers/indexing'; type Props = { displayAllIssues: boolean, @@ -39,7 +40,7 @@ type Props = { filtered: boolean | null, highlighted: boolean, highlightedSymbol: string | null, - issueLocations: Array<{ from: number, to: number }>, + issueLocations: Array<LinearIssueLocation>, issues: Array<string>, line: SourceLine, loadDuplications: (SourceLine, HTMLElement) => void, @@ -49,12 +50,13 @@ type Props = { onIssueSelect: (string) => void, onIssueUnselect: () => void, onSCMClick: (SourceLine, HTMLElement) => void, + onSelectLocation: (flowIndex: number, locationIndex: number) => void, onSymbolClick: (string) => void, selectedIssue: string | null, + secondaryIssueLocations: Array<IndexedIssueLocation>, // $FlowFixMe - secondaryIssueLocations: Array<{ from: number, to: number }>, - // $FlowFixMe - secondaryIssueLocationMessages: Array<{ msg: string, index?: number }> + secondaryIssueLocationMessages: Array<IndexedIssueLocationMessage>, + selectedIssueLocation: IndexedIssueLocation | null }; type State = { @@ -77,16 +79,7 @@ export default class SourceViewerLine extends React.PureComponent { this.detachEvents(); } - componentDidUpdate (prevProps: Props) { - /* eslint-disable no-console */ - console.log('re-render line', this.props.line.line, 'because they are not equal:'); - Object.keys(this.props).forEach(prop => { - if (this.props[prop] !== prevProps[prop]) { - console.log(prop); - } - }); - console.log(''); - + componentDidUpdate () { this.attachEvents(); } @@ -276,21 +269,49 @@ export default class SourceViewerLine extends React.PureComponent { ); } - renderSecondaryIssueLocationMessages (locationMessages: Array<{ msg: string, index?: number }>) { + isSecondaryIssueLocationSelected (location: IndexedIssueLocation | IndexedIssueLocationMessage) { + const { selectedIssueLocation } = this.props; + if (selectedIssueLocation == null) { + return false; + } else { + return selectedIssueLocation.flowIndex === location.flowIndex && + selectedIssueLocation.locationIndex === location.locationIndex; + } + } + + handleLocationMessageClick (flowIndex: number, locationIndex: number, e: SyntheticInputEvent) { + e.preventDefault(); + this.props.onSelectLocation(flowIndex, locationIndex); + } + + renderSecondaryIssueLocationMessage = (location: IndexedIssueLocationMessage) => { + const className = classNames('source-viewer-issue-location', 'issue-location-message', { + 'selected': this.isSecondaryIssueLocationSelected(location) + }); + const limitString = (str: string) => ( str.length > 30 ? str.substr(0, 30) + '...' : str ); return ( + <a + key={`${location.flowIndex}-${location.locationIndex}`} + href="#" + className={className} + title={location.msg} + onClick={e => this.handleLocationMessageClick(location.flowIndex, location.locationIndex, e)}> + {location.index && ( + <strong>{location.index}: </strong> + )} + {limitString(location.msg)} + </a> + ); + }; + + renderSecondaryIssueLocationMessages (locations: Array<IndexedIssueLocationMessage>) { + return ( <div className="source-line-issue-locations"> - {locationMessages.map((locationMessage, index) => ( - <div key={index} className="source-viewer-issue-location" title={locationMessage.msg}> - {locationMessage.index && ( - <strong>{locationMessage.index}: </strong> - )} - {limitString(locationMessage.msg)} - </div> - ))} + {locations.map(this.renderSecondaryIssueLocationMessage)} </div> ); } @@ -312,7 +333,19 @@ export default class SourceViewerLine extends React.PureComponent { } if (secondaryIssueLocations) { - tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'source-line-code-secondary-issue'); + const linearLocations = secondaryIssueLocations.map(location => ({ + from: location.from, + line: location.line, + to: location.to + })); + tokens = highlightIssueLocations(tokens, linearLocations, 'issue-location'); + const { selectedIssueLocation } = this.props; + if (selectedIssueLocation != null) { + const x = secondaryIssueLocations.find(location => this.isSecondaryIssueLocationSelected(location)); + if (x) { + tokens = highlightIssueLocations(tokens, [x], 'selected'); + } + } } const finalCode = generateHTML(tokens); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js index 0adc3f0d31f..c0ca46bb9d1 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js @@ -19,6 +19,7 @@ */ // @flow import escapeHtml from 'escape-html'; +import type { LinearIssueLocation } from './indexing'; type Token = { className: string, text: string }; type Tokens = Array<Token>; @@ -78,7 +79,7 @@ const part = (str: string, from: number, to: number, acc: number): string => { */ export const highlightIssueLocations = ( tokens: Tokens, - issueLocations: Array<{ from: number, to: number }>, + issueLocations: Array<LinearIssueLocation>, rootClassName: string = ISSUE_LOCATION_CLASS ): Tokens => { issueLocations.forEach(location => { diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js index a9016ef0c7c..dcfe2f273fa 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js @@ -23,6 +23,39 @@ import { getLinearLocations, getIssueLocations } from './issueLocations'; import type { Issue } from '../../issue/types'; import type { SourceLine } from '../types'; +export type LinearIssueLocation = { + from: number, + line: number, + to: number +}; + +export type IndexedIssueLocation = { + flowIndex: number, + from: number, + line: number, + locationIndex: number, + to: number, +}; + +export type IndexedIssueLocationMessage = { + flowIndex: number, + locationIndex: number, + msg: string +}; + +export type IndexedIssueLocationsByIssueAndLine = { + [issueKey: string]: { + // $FlowFixMe + [lineNumber: number]: Array<IndexedIssueLocation> + } +}; + +export type IndexedIssueLocationMessagesByIssueAndLine = { + [issueKey: string]: { + [lineNumber: number]: Array<IndexedIssueLocationMessage> + } +}; + export const issuesByLine = (issues: Array<Issue>) => { const index = {}; issues.forEach(issue => { @@ -35,7 +68,7 @@ export const issuesByLine = (issues: Array<Issue>) => { return index; }; -export const locationsByLine = (issues: Array<Issue>) => { +export const locationsByLine = (issues: Array<Issue>): { [number]: Array<LinearIssueLocation> } => { const index = {}; issues.forEach(issue => { getLinearLocations(issue.textRange).forEach(location => { @@ -48,7 +81,7 @@ export const locationsByLine = (issues: Array<Issue>) => { return index; }; -export const locationsByIssueAndLine = (issues: Array<Issue>) => { +export const locationsByIssueAndLine = (issues: Array<Issue>): IndexedIssueLocationsByIssueAndLine => { const index = {}; issues.forEach(issue => { const byLine = {}; @@ -57,7 +90,11 @@ export const locationsByIssueAndLine = (issues: Array<Issue>) => { if (!(linearLocation.line in byLine)) { byLine[linearLocation.line] = []; } - byLine[linearLocation.line].push({ from: linearLocation.from, to: linearLocation.to }); + byLine[linearLocation.line].push({ + ...linearLocation, + flowIndex: location.flowIndex, + locationIndex: location.locationIndex + }); }); }); index[issue.key] = byLine; @@ -65,7 +102,7 @@ export const locationsByIssueAndLine = (issues: Array<Issue>) => { return index; }; -export const locationMessagesByIssueAndLine = (issues: Array<Issue>) => { +export const locationMessagesByIssueAndLine = (issues: Array<Issue>): IndexedIssueLocationMessagesByIssueAndLine => { const index = {}; issues.forEach(issue => { const byLine = {}; @@ -74,7 +111,7 @@ export const locationMessagesByIssueAndLine = (issues: Array<Issue>) => { if (!(line in byLine)) { byLine[line] = []; } - byLine[line].push({ msg: location.msg, index: location.index }); + byLine[line].push(location); }); index[issue.key] = byLine; }); @@ -117,3 +154,23 @@ export const symbolsByLine = (sources: Array<SourceLine>) => { }); return index; }; + +export const findLocationByIndex = ( + locations: IndexedIssueLocationsByIssueAndLine, + flowIndex: number, + locationIndex: number) => { + const issueKeys = Object.keys(locations); + for (const issueKey of issueKeys) { + const lineNumbers = Object.keys(locations[issueKey]); + for (let lineIndex = 0; lineIndex < lineNumbers.length; lineIndex++) { + for (let i = 0; i < locations[issueKey][lineNumbers[lineIndex]].length; i++) { + const location = locations[issueKey][lineNumbers[lineIndex]][i]; + if (location.flowIndex === flowIndex && location.locationIndex === locationIndex) { + return location; + } + } + } + } + + return null; +}; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js index d2c8991fc3c..70af97af1a5 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js @@ -36,18 +36,22 @@ export const getLinearLocations = (textRange?: TextRange): Array<{ line: number, return locations; }; -export const getIssueLocations = (issue: Issue): Array<{ msg: string, textRange: TextRange, index?: number }> => { - const primaryLocation = { - msg: issue.message, - textRange: issue.textRange - }; - const allLocations = [primaryLocation]; - issue.flows.forEach(({ locations }) => { +export const getIssueLocations = (issue: Issue): Array<{ + msg: string, + flowIndex: number, + locationIndex: number, + textRange?: TextRange, + index?: number +}> => { + const allLocations = []; + issue.flows.forEach(({ locations }, flowIndex) => { if (locations) { const locationsCount = locations.length; locations.forEach((location, index) => { const flowLocation = { ...location, + flowIndex, + locationIndex: index, // set index only for real flows, do not set for just secondary locations index: locationsCount > 1 ? locationsCount - index : undefined }; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/styles.css b/server/sonar-web/src/main/js/components/SourceViewer/styles.css new file mode 100644 index 00000000000..6371f9e8cb3 --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/styles.css @@ -0,0 +1,92 @@ +.source-issue-locations { + position: relative; +} + +.source-issue-locations-panel { + background-color: #fff; + box-shadow: 0 -6px 12px rgba(0, 0, 0, .175); +} + +.source-issue-locations-panel.fixed { + position: fixed; + bottom: 0; + margin-left: -1px; + border-left: 1px solid #e6e6e6; + border-right: 1px solid #e6e6e6; +} + +.source-issue-locations-header { + height: 15px; + padding: 0 15px; + box-sizing: border-box; + background-color: #404040; + color: #fff; +} + +.source-issue-locations-shortcuts { + position: absolute; + top: 18px; + right: 18px; + padding: 6px; + background-color: #fff; + color: #777; + font-size: 11px; +} + +.source-issue-locations-list { + height: 185px; + padding: 15px; + box-sizing: border-box; + overflow: auto; +} + +.source-issue-locations-line { + display: inline-block; + min-width: 25px; + margin-right: 15px; + color: #777; + font-size: 12px; + text-align: right; +} + +.issue-location, +.issue-location-message { + display: inline-block; + vertical-align: top; + line-height: 16px; + height: 17px; + border: 1px solid #ffeaea; + box-sizing: border-box; + background-color: #ffeaea; +} + +.issue-location { + /* nothing so far */ +} + +.issue-location.highlighted { + border-color: #e1e1f2; + background-color: #e1e1f2; +} + +.issue-location.selected { + border-color: #f4b1b0; + background-color: #f4b1b0; +} + +.issue-location-message { + padding: 0 10px; + color: #444 !important; + font-size: 12px; + white-space: nowrap; + transition: all 0.3s ease; +} + +.issue-location-message:hover { + border-color: #f4b1b0; + background-color: #f4b1b0; +} + +.issue-location-message.selected { + border-color: #dd4040; +}
\ No newline at end of file diff --git a/server/sonar-web/src/main/js/components/issue/Issue.js b/server/sonar-web/src/main/js/components/issue/Issue.js index c437b8f41af..ce7781caabb 100644 --- a/server/sonar-web/src/main/js/components/issue/Issue.js +++ b/server/sonar-web/src/main/js/components/issue/Issue.js @@ -42,6 +42,10 @@ class Issue extends React.PureComponent { node: HTMLElement; props: Props; + static defaultProps = { + selected: false + }; + componentDidMount () { this.renderIssueView(); if (this.props.selected) { diff --git a/server/sonar-web/src/main/js/components/issue/types.js b/server/sonar-web/src/main/js/components/issue/types.js index dd0bbc1d2e6..9d3982f8f28 100644 --- a/server/sonar-web/src/main/js/components/issue/types.js +++ b/server/sonar-web/src/main/js/components/issue/types.js @@ -25,13 +25,15 @@ export type TextRange = { endOffset: number }; +export type FlowLocation = { + msg: string, + textRange?: TextRange +}; + export type Issue = { key: string, flows: Array<{ - locations?: Array<{ - msg: string, - textRange?: TextRange - }> + locations?: Array<FlowLocation> }>, line?: number, message: string, diff --git a/server/sonar-web/src/main/js/components/issue/views/changelog-view.js b/server/sonar-web/src/main/js/components/issue/views/changelog-view.js index 95c7c6da664..f57d968f14d 100644 --- a/server/sonar-web/src/main/js/components/issue/views/changelog-view.js +++ b/server/sonar-web/src/main/js/components/issue/views/changelog-view.js @@ -34,4 +34,3 @@ export default PopupView.extend({ }; } }); - diff --git a/server/sonar-web/src/main/js/helpers/issues.js b/server/sonar-web/src/main/js/helpers/issues.js index 3a1e509f790..6410fe3e25c 100644 --- a/server/sonar-web/src/main/js/helpers/issues.js +++ b/server/sonar-web/src/main/js/helpers/issues.js @@ -41,6 +41,13 @@ type RawIssue = { author: string, comments?: Array<Comment>, component: string, + flows: Array<{ + locations: Array<{ + msg: string, + textRange: TextRange + }> + }>, + key: string, line?: number, project: string, rule: string, diff --git a/server/sonar-web/src/main/js/helpers/scrolling.js b/server/sonar-web/src/main/js/helpers/scrolling.js new file mode 100644 index 00000000000..e456eb3b340 --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/scrolling.js @@ -0,0 +1,83 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +// @flow +import debounce from 'lodash/debounce'; + +const SCROLLING_DURATION = 100; +const SCROLLING_INTERVAL = 10; +const SCROLLING_STEPS = SCROLLING_DURATION / SCROLLING_INTERVAL; + +const getScrollPosition = (element: HTMLElement): number => { + return element === window ? window.scrollY : element.scrollTop; +}; + +const scrollElement = (element: HTMLElement, position: number) => { + if (element === window) { + window.scrollTo(0, position); + } else { + element.scrollTop = position; + } +}; + +let smoothScrollTop = (y: number, parent) => { + const scrollTop = getScrollPosition(parent); + const scrollingDown = y > scrollTop; + const step = Math.ceil(Math.abs(y - scrollTop) / SCROLLING_STEPS); + let stepsDone = 0; + + const interval = setInterval(() => { + const scrollTop = getScrollPosition(parent); + if (scrollTop === y || SCROLLING_STEPS === stepsDone) { + clearInterval(interval); + } else { + let goal; + if (scrollingDown) { + goal = Math.min(y, scrollTop + step); + } else { + goal = Math.max(y, scrollTop - step); + } + stepsDone++; + scrollElement(parent, goal); + } + }, SCROLLING_INTERVAL); +}; + +smoothScrollTop = debounce(smoothScrollTop, SCROLLING_DURATION, { leading: true }); + +export const scrollToElement = ( + element: HTMLElement, + topOffset: number = 0, + bottomOffset: number = 0, + parent: HTMLElement = window +) => { + const { top, bottom } = element.getBoundingClientRect(); + const scrollTop = getScrollPosition(parent); + const height: number = parent === window ? window.innerHeight : parent.getBoundingClientRect().height; + + const parentTop = parent === window ? 0 : parent.getBoundingClientRect().top; + + if (top - parentTop < topOffset) { + smoothScrollTop(scrollTop - topOffset + top - parentTop, parent); + } + + if (bottom - parentTop > height - bottomOffset) { + smoothScrollTop(scrollTop + bottom - parentTop - height + bottomOffset, parent); + } +}; diff --git a/server/sonar-web/src/main/less/components/source.less b/server/sonar-web/src/main/less/components/source.less index 9a89d87959a..07485b239d2 100644 --- a/server/sonar-web/src/main/less/components/source.less +++ b/server/sonar-web/src/main/less/components/source.less @@ -127,15 +127,6 @@ background-position: bottom; } -.source-line-code-secondary-issue { - display: inline-block; - background-color: @issueBackgroundColor; - - &.highlighted { - background-color: mix(#B3D4FF, @issueBackgroundColor, 40%); - } -} - .source-meta { vertical-align: top; width: 1px; @@ -461,14 +452,8 @@ } .source-viewer-issue-location { - float: right; max-width: 200px; - height: @source-line-height - 1px; - line-height: @source-line-height - 1px; margin-right: 10px; - padding: 0 10px; - background-color: #ffeaea; - font-size: 12px; .text-ellipsis; } @@ -479,7 +464,6 @@ .source-line-issue-locations { float: right; margin-right: -10px; - padding-bottom: 1px; &:empty { display: none; diff --git a/server/sonar-web/src/main/less/components/ui.less b/server/sonar-web/src/main/less/components/ui.less index a0a852de9ad..357e37ebb9f 100644 --- a/server/sonar-web/src/main/less/components/ui.less +++ b/server/sonar-web/src/main/less/components/ui.less @@ -95,7 +95,7 @@ display: inline-block; min-width: 24px; height: 24px; - line-height: 24px; + line-height: 21px; padding: 0 4px; box-sizing: border-box; border: 1px solid #ccc; @@ -103,8 +103,7 @@ background-image: linear-gradient(to bottom, #f5f5f5, #eee); box-shadow: inset 0 1px 0 #fff, 0 1px 0 #ccc; color: @secondFontColor; - font-size: @baseFontSize; - font-weight: 600; + font-size: 11px; text-align: center; } diff --git a/server/sonar-web/src/main/less/pages/issues.less b/server/sonar-web/src/main/less/pages/issues.less index 98bf375c2ea..3c25afc7d90 100644 --- a/server/sonar-web/src/main/less/pages/issues.less +++ b/server/sonar-web/src/main/less/pages/issues.less @@ -59,7 +59,7 @@ .issues-workspace-component-viewer { display: none; - padding: 1px 10px; + padding: 1px 10px 10px; min-height: 100vh; .code-issue-modern { |