aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js')
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js499
1 files changed, 499 insertions, 0 deletions
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
new file mode 100644
index 00000000000..2ad750e7120
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js
@@ -0,0 +1,499 @@
+/*
+ * 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 classNames from 'classnames';
+import uniqBy from 'lodash/uniqBy';
+import SourceViewerHeader from './SourceViewerHeader';
+import SourceViewerCode from './SourceViewerCode';
+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';
+import {
+ issuesByLine,
+ locationsByLine,
+ locationsByIssueAndLine,
+ locationMessagesByIssueAndLine,
+ duplicationsByLine,
+ symbolsByLine
+} from './helpers/indexing';
+import { getComponentForSourceViewer, getSources, getDuplications, getTests } from '../../api/components';
+import { translate } from '../../helpers/l10n';
+import type { SourceLine } from './types';
+import type { Issue } from '../issue/types';
+
+// TODO react-virtualized
+
+type Props = {
+ aroundLine?: number,
+ component: string,
+ displayAllIssues: boolean,
+ filterLine?: (line: SourceLine) => boolean,
+ highlightedLine?: number,
+ loadComponent: (string) => Promise<*>,
+ loadIssues: (string, number, number) => Promise<*>,
+ loadSources: (string, number, number) => Promise<*>,
+ onLoaded?: (component: Object, sources: Array<*>, issues: Array<*>) => void,
+ onIssueSelect: (string) => void,
+ onIssueUnselect: () => void,
+ onReceiveComponent: ({ canMarkAsFavorite: boolean, fav: boolean, key: string }) => void,
+ onReceiveIssues: (issues: Array<*>) => void,
+ selectedIssue: string | null,
+};
+
+type State = {
+ component?: Object,
+ displayDuplications: boolean,
+ duplications?: Array<{
+ blocks: Array<{
+ _ref: string,
+ from: number,
+ size: number
+ }>
+ }>,
+ duplicationsByLine: { [number]: Array<number> },
+ duplicatedFiles?: Array<{ key: string }>,
+ hasSourcesAfter: boolean,
+ highlightedLine: number | null,
+ 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 }>
+ }
+ },
+ loading: boolean,
+ loadingSourcesAfter: boolean,
+ loadingSourcesBefore: boolean,
+ notAccessible: boolean,
+ notExist: boolean,
+ sources?: Array<SourceLine>,
+ symbolsByLine: { [number]: Array<string> }
+};
+
+const LINES = 500;
+
+const loadComponent = (key: string): Promise<*> => {
+ return getComponentForSourceViewer(key);
+};
+
+const loadSources = (key: string, from?: number, to?: number): Promise<Array<*>> => {
+ return getSources(key, from, to);
+};
+
+export default class SourceViewerBase extends React.Component {
+ mounted: boolean;
+ node: HTMLElement;
+ props: Props;
+ state: State;
+
+ static defaultProps = {
+ displayAllIssues: false,
+ onIssueSelect: () => { },
+ onIssueUnselect: () => { },
+ loadComponent,
+ loadIssues,
+ loadSources
+ };
+
+ constructor (props: Props) {
+ super(props);
+ this.state = {
+ displayDuplications: false,
+ duplicationsByLine: {},
+ hasSourcesAfter: false,
+ highlightedLine: props.highlightedLine || null,
+ highlightedSymbol: null,
+ issuesByLine: {},
+ issueLocationsByLine: {},
+ issueSecondaryLocationsByIssueByLine: {},
+ issueSecondaryLocationMessagesByIssueByLine: {},
+ loading: true,
+ loadingSourcesAfter: false,
+ loadingSourcesBefore: false,
+ notAccessible: false,
+ notExist: false,
+ selectedIssue: props.defaultSelectedIssue || null,
+ symbolsByLine: {}
+ };
+ }
+
+ componentDidMount () {
+ this.mounted = true;
+ this.fetchComponent();
+ }
+
+ componentDidUpdate (prevProps: Props) {
+ 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();
+ }
+ }
+
+ componentWillUnmount () {
+ this.mounted = false;
+ }
+
+ computeCoverageStatus (lines: Array<SourceLine>): Array<SourceLine> {
+ return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) }));
+ }
+
+ isLineOutsideOfRange (lineNumber: number) {
+ const { sources } = this.state;
+ if (sources != null && sources.length > 0) {
+ const firstLine = sources[0];
+ const lastList = sources[sources.length - 1];
+ return lineNumber < firstLine.line || lineNumber > lastList.line;
+ } else {
+ return true;
+ }
+ }
+
+ fetchComponent () {
+ this.setState({ loading: true });
+
+ const loadIssues = (component, sources) => {
+ this.props.loadIssues(this.props.component, 1, LINES).then(issues => {
+ this.props.onReceiveIssues(issues);
+ if (this.mounted) {
+ const finalSources = sources.slice(0, LINES);
+ this.setState({
+ component,
+ issues,
+ issuesByLine: issuesByLine(issues),
+ issueLocationsByLine: locationsByLine(issues),
+ issueSecondaryLocationsByIssueByLine: locationsByIssueAndLine(issues),
+ issueSecondaryLocationMessagesByIssueByLine: locationMessagesByIssueAndLine(issues),
+ loading: false,
+ hasSourcesAfter: sources.length > LINES,
+ sources: this.computeCoverageStatus(finalSources),
+ symbolsByLine: symbolsByLine(sources.slice(0, LINES))
+ }, () => {
+ if (this.props.onLoaded) {
+ this.props.onLoaded(component, finalSources, issues);
+ }
+ });
+ }
+ });
+ };
+
+ const onFailLoadComponent = ({ response }) => {
+ // TODO handle other statuses
+ if (this.mounted && response.status === 404) {
+ this.setState({ loading: false, notExist: true });
+ }
+ };
+
+ const onFailLoadSources = (response, component) => {
+ // TODO handle other statuses
+ if (this.mounted) {
+ if (response.status === 403) {
+ this.setState({ component, loading: false, notAccessible: true });
+ }
+ }
+ };
+
+ const onResolve = component => {
+ this.props.onReceiveComponent(component);
+ this.loadSources().then(
+ sources => loadIssues(component, sources),
+ response => onFailLoadSources(response, component)
+ );
+ };
+
+ this.props.loadComponent(this.props.component).then(onResolve, onFailLoadComponent);
+ }
+
+ fetchSources () {
+ this.loadSources().then(sources => {
+ if (this.mounted) {
+ const finalSources = sources.slice(0, LINES);
+ this.setState({
+ sources: sources.slice(0, LINES),
+ hasSourcesAfter: sources.length > LINES
+ }, () => {
+ if (this.props.onLoaded) {
+ // $FlowFixMe
+ this.props.onLoaded(this.state.component, finalSources, this.state.issues);
+ }
+ });
+ }
+ });
+ }
+
+ loadSources () {
+ return new Promise((resolve, reject) => {
+ const onFailLoadSources = ({ response }) => {
+ // TODO handle other statuses
+ if (this.mounted) {
+ if (response.status === 403) {
+ reject(response);
+ } else if (response.status === 404) {
+ resolve([]);
+ }
+ }
+ };
+
+ const from = this.props.aroundLine ? Math.max(1, this.props.aroundLine - LINES / 2 + 1) : 1;
+ // request one additional line to define `hasSourcesAfter`
+ const to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1;
+
+ return this.props.loadSources(this.props.component, from, to).then(
+ sources => resolve(sources),
+ onFailLoadSources
+ );
+ });
+ }
+
+ loadSourcesBefore = () => {
+ if (!this.state.sources) {
+ return;
+ }
+ const firstSourceLine = this.state.sources[0];
+ this.setState({ loadingSourcesBefore: true });
+ const from = Math.max(1, firstSourceLine.line - LINES);
+ this.props.loadSources(this.props.component, from, firstSourceLine.line - 1).then(sources => {
+ this.props.loadIssues(this.props.component, from, firstSourceLine.line - 1).then(issues => {
+ this.props.onReceiveIssues(issues);
+ if (this.mounted) {
+ this.setState(prevState => ({
+ issues: uniqBy([...issues, ...prevState.issues], issue => issue.key),
+ loadingSourcesBefore: false,
+ sources: [...this.computeCoverageStatus(sources), ...prevState.sources],
+ symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) }
+ }));
+ }
+ });
+ });
+ };
+
+ loadSourcesAfter = () => {
+ if (!this.state.sources) {
+ return;
+ }
+ const lastSourceLine = this.state.sources[this.state.sources.length - 1];
+ this.setState({ loadingSourcesAfter: true });
+ const fromLine = lastSourceLine.line + 1;
+ // request one additional line to define `hasSourcesAfter`
+ const toLine = lastSourceLine.line + LINES + 1;
+ this.props.loadSources(this.props.component, fromLine, toLine).then(sources => {
+ this.props.loadIssues(this.props.component, fromLine, toLine).then(issues => {
+ this.props.onReceiveIssues(issues);
+ if (this.mounted) {
+ this.setState(prevState => ({
+ issues: uniqBy([...prevState.issues, ...issues], issue => issue.key),
+ hasSourcesAfter: sources.length > LINES,
+ loadingSourcesAfter: false,
+ sources: [...prevState.sources, ...this.computeCoverageStatus(sources.slice(0, LINES))],
+ symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources.slice(0, LINES)) }
+ }));
+ }
+ });
+ });
+ };
+
+ loadDuplications = (line: SourceLine, element: HTMLElement) => {
+ getDuplications(this.props.component).then(r => {
+ if (this.mounted) {
+ this.setState({
+ displayDuplications: true,
+ duplications: r.duplications,
+ duplicationsByLine: duplicationsByLine(r.duplications),
+ duplicatedFiles: r.files
+ }, () => {
+ // immediately show dropdown popup if there is only one duplicated block
+ if (r.duplications.length === 1) {
+ this.handleDuplicationClick(0, line.line, element);
+ }
+ });
+ }
+ });
+ };
+
+ openNewWindow = () => {
+ const { component } = this.state;
+ if (component != null) {
+ let query = 'id=' + encodeURIComponent(component.key);
+ const windowParams = 'resizable=1,scrollbars=1,status=1';
+ if (this.state.highlightedLine) {
+ query = query + '&line=' + this.state.highlightedLine;
+ }
+ window.open(window.baseUrl + '/component/index?' + query, component.name, windowParams);
+ }
+ };
+
+ showMeasures = () => {
+ const model = new Source(this.state.component);
+ const measuresOvervlay = new MeasuresOverlay({ model, large: true });
+ measuresOvervlay.render();
+ };
+
+ handleCoverageClick = (line: SourceLine, element: HTMLElement) => {
+ getTests(this.props.component, line.line).then(tests => {
+ const popup = new CoveragePopupView({ line, tests, triggerEl: element });
+ popup.render();
+ });
+ };
+
+ handleDuplicationClick = (index: number, line: number) => {
+ const duplication = this.state.duplications && this.state.duplications[index];
+ let blocks = (duplication && duplication.blocks) || [];
+ const inRemovedComponent = blocks.some(b => b._ref == null);
+ let foundOne = false;
+ blocks = blocks.filter(b => {
+ const outOfBounds = b.from > line || b.from + b.size < line;
+ const currentFile = b._ref === '1';
+ const shouldDisplayForCurrentFile = outOfBounds || foundOne;
+ const shouldDisplay = !currentFile || shouldDisplayForCurrentFile;
+ const isOk = (b._ref != null) && shouldDisplay;
+ if (b._ref === '1' && !outOfBounds) {
+ foundOne = true;
+ }
+ return isOk;
+ });
+
+ const element = this.node.querySelector(`.source-line-duplications-extra[data-line-number="${line}"]`);
+ if (element) {
+ const popup = new DuplicationPopupView({
+ blocks,
+ inRemovedComponent,
+ component: this.state.component,
+ files: this.state.duplicatedFiles,
+ triggerEl: element
+ });
+ popup.render();
+ }
+ };
+
+ displayLinePopup (line: number, element: HTMLElement) {
+ const popup = new LineActionsPopupView({
+ line,
+ triggerEl: element,
+ component: this.state.component
+ });
+ popup.render();
+ }
+
+ handleLineClick = (line: number, element: HTMLElement) => {
+ this.setState(prevState => ({
+ highlightedLine: prevState.highlightedLine === line ? null : line
+ }));
+ this.displayLinePopup(line, element);
+ };
+
+ handleSymbolClick = (symbol: string) => {
+ this.setState(prevState => ({
+ highlightedSymbol: prevState.highlightedSymbol === symbol ? null : symbol
+ }));
+ };
+
+ handleSCMClick = (line: SourceLine, element: HTMLElement) => {
+ const popup = new SCMPopupView({ triggerEl: element, line });
+ popup.render();
+ };
+
+ 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>
+ );
+ }
+
+ render () {
+ const { component, loading } = this.state;
+
+ if (loading) {
+ return null;
+ }
+
+ if (this.state.notExist) {
+ return (
+ <div className="alert alert-warning spacer-top">{translate('component_viewer.no_component')}</div>
+ );
+ }
+
+ if (component == null) {
+ return null;
+ }
+
+ const className = classNames('source-viewer', { 'source-duplications-expanded': this.state.displayDuplications });
+
+ return (
+ <div className={className} ref={node => this.node = node}>
+ <SourceViewerHeader
+ component={this.state.component}
+ openNewWindow={this.openNewWindow}
+ showMeasures={this.showMeasures}/>
+ {this.state.notAccessible && (
+ <div className="alert alert-warning spacer-top">
+ {translate('code_viewer.no_source_code_displayed_due_to_security')}
+ </div>
+ )}
+ {this.state.sources != null && this.renderCode(this.state.sources)}
+ </div>
+ );
+ }
+}