/* * SonarQube * Copyright (C) 2009-2018 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 * as classNames from 'classnames'; import { intersection, uniqBy } from 'lodash'; import SourceViewerHeader from './SourceViewerHeader'; import SourceViewerCode from './SourceViewerCode'; import DuplicationPopup from './components/DuplicationPopup'; import defaultLoadIssues from './helpers/loadIssues'; import getCoverageStatus from './helpers/getCoverageStatus'; import { duplicationsByLine, issuesByLine, locationsByLine, symbolsByLine } from './helpers/indexing'; import { getComponentData, getComponentForSourceViewer, getDuplications, getSources } from '../../api/components'; import { BranchLike, DuplicatedFile, Duplication, FlowLocation, Issue, LinearIssueLocation, SourceLine, SourceViewerFile } from '../../app/types'; import { isSameBranchLike, getBranchLikeQuery } from '../../helpers/branches'; import { parseDate } from '../../helpers/dates'; import { translate } from '../../helpers/l10n'; import './styles.css'; // TODO react-virtualized export interface Props { aroundLine?: number; branchLike: BranchLike | undefined; component: string; displayAllIssues?: boolean; displayIssueLocationsCount?: boolean; displayIssueLocationsLink?: boolean; displayLocationMarkers?: boolean; highlightedLine?: number; // `undefined` elements mean they are located in a different file, // but kept to maintaint the location indexes highlightedLocations?: (FlowLocation | undefined)[]; highlightedLocationMessage?: { index: number; text: string | undefined }; loadComponent?: ( component: string, branchLike: BranchLike | undefined ) => Promise; loadIssues?: ( component: string, from: number, to: number, branchLike: BranchLike | undefined ) => Promise; loadSources?: ( component: string, from: number, to: number, branchLike: BranchLike | undefined ) => Promise; onLoaded?: (component: SourceViewerFile, sources: SourceLine[], issues: Issue[]) => void; onLocationSelect?: (index: number) => void; onIssueChange?: (issue: Issue) => void; onIssueSelect?: (issueKey: string) => void; onIssueUnselect?: () => void; onReceiveComponent: (component: SourceViewerFile) => void; scroll?: (element: HTMLElement) => void; selectedIssue?: string; } interface State { component?: SourceViewerFile; displayDuplications: boolean; duplicatedFiles?: { [ref: string]: DuplicatedFile }; duplications?: Duplication[]; duplicationsByLine: { [line: number]: number[] }; hasSourcesAfter: boolean; highlightedSymbols: string[]; issueLocationsByLine: { [line: number]: LinearIssueLocation[] }; issuePopup?: { issue: string; name: string }; issues?: Issue[]; issuesByLine: { [line: number]: Issue[] }; linePopup?: { index?: number; line: number; name: string }; loading: boolean; loadingSourcesAfter: boolean; loadingSourcesBefore: boolean; notAccessible: boolean; notExist: boolean; openIssuesByLine: { [line: number]: boolean }; selectedIssue?: string; sourceRemoved: boolean; sources?: SourceLine[]; symbolsByLine: { [line: number]: string[] }; } const LINES = 500; export default class SourceViewerBase extends React.PureComponent { node?: HTMLElement | null; mounted = false; static defaultProps = { displayAllIssues: false, displayIssueLocationsCount: true, displayIssueLocationsLink: true, displayLocationMarkers: true }; constructor(props: Props) { super(props); this.state = { displayDuplications: false, duplicationsByLine: {}, hasSourcesAfter: false, highlightedSymbols: [], issuesByLine: {}, issueLocationsByLine: {}, loading: true, loadingSourcesAfter: false, loadingSourcesBefore: false, notAccessible: false, notExist: false, openIssuesByLine: {}, selectedIssue: props.selectedIssue, sourceRemoved: false, symbolsByLine: {} }; } componentDidMount() { this.mounted = true; this.fetchComponent(); } componentWillReceiveProps(nextProps: Props) { // if a component or a branch has changed, // set `loading: true` immediately to avoid unwanted scrolling in `LineCode` if ( nextProps.component !== this.props.component || !isSameBranchLike(nextProps.branchLike, this.props.branchLike) ) { this.setState({ loading: true }); } if ( nextProps.onIssueSelect !== undefined && nextProps.selectedIssue !== this.props.selectedIssue ) { this.setState({ selectedIssue: nextProps.selectedIssue }); } } componentDidUpdate(prevProps: Props) { if ( prevProps.component !== this.props.component || !isSameBranchLike(prevProps.branchLike, this.props.branchLike) ) { this.fetchComponent(); } else if ( this.props.aroundLine !== undefined && prevProps.aroundLine !== this.props.aroundLine && this.isLineOutsideOfRange(this.props.aroundLine) ) { this.fetchSources(); } else { const { selectedIssue } = this.props; const { issues } = this.state; if ( selectedIssue !== undefined && issues !== undefined && issues.find(issue => issue.key === selectedIssue) === undefined ) { this.reloadIssues(); } } } componentWillUnmount() { this.mounted = false; } // react typings do not take `defaultProps` into account, // so use these getters to get type-safe methods get safeLoadComponent() { return this.props.loadComponent || defaultLoadComponent; } get safeLoadIssues() { return this.props.loadIssues || defaultLoadIssues; } get safeLoadSources() { return this.props.loadSources || defaultLoadSources; } computeCoverageStatus(lines: SourceLine[]) { return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) })); } isLineOutsideOfRange(lineNumber: number) { const { sources } = this.state; if (sources && 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: SourceViewerFile, sources: SourceLine[]) => { this.safeLoadIssues(this.props.component, 1, LINES, this.props.branchLike).then( issues => { if (this.mounted) { const finalSources = sources.slice(0, LINES); this.setState( { component, displayDuplications: false, duplicatedFiles: undefined, duplications: undefined, duplicationsByLine: {}, hasSourcesAfter: sources.length > LINES, highlightedSymbols: [], issueLocationsByLine: locationsByLine(issues), issues, issuesByLine: issuesByLine(issues), linePopup: undefined, loading: false, notAccessible: false, notExist: false, openIssuesByLine: {}, issuePopup: undefined, sourceRemoved: false, sources: this.computeCoverageStatus(finalSources), symbolsByLine: symbolsByLine(sources.slice(0, LINES)) }, () => { if (this.props.onLoaded) { this.props.onLoaded(component, finalSources, issues); } } ); } }, () => { // TODO } ); }; const onFailLoadComponent = ({ response }: { response: Response }) => { // TODO handle other statuses if (this.mounted) { if (response.status === 403) { this.setState({ loading: false, notAccessible: true }); } else if (response.status === 404) { this.setState({ loading: false, notExist: true }); } } }; const onFailLoadSources = (response: Response, component: SourceViewerFile) => { // TODO handle other statuses if (this.mounted) { if (response.status === 403) { this.setState({ component, loading: false, notAccessible: true }); } else if (response.status === 404) { this.setState({ component, loading: false, sourceRemoved: true }); } } }; const onResolve = (component: SourceViewerFile) => { this.props.onReceiveComponent(component); const sourcesRequest = component.q === 'FIL' || component.q === 'UTS' ? this.loadSources() : Promise.resolve([]); sourcesRequest.then( sources => loadIssues(component, sources), response => onFailLoadSources(response, component) ); }; this.safeLoadComponent(this.props.component, this.props.branchLike).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 && this.state.component && this.state.issues) { this.props.onLoaded(this.state.component, finalSources, this.state.issues); } } ); } }, () => { // TODO } ); } reloadIssues() { if (!this.state.sources) { return; } const firstSourceLine = this.state.sources[0]; const lastSourceLine = this.state.sources[this.state.sources.length - 1]; this.safeLoadIssues( this.props.component, firstSourceLine && firstSourceLine.line, lastSourceLine && lastSourceLine.line, this.props.branchLike ).then( issues => { if (this.mounted) { this.setState({ issues, issuesByLine: issuesByLine(issues), issueLocationsByLine: locationsByLine(issues) }); } }, () => { // TODO } ); } loadSources = (): Promise => { return new Promise((resolve, reject) => { const onFailLoadSources = ({ response }: { response: Response }) => { // TODO handle other statuses if (this.mounted) { if ([403, 404].includes(response.status)) { reject(response); } else { resolve([]); } } }; const from = this.props.aroundLine ? Math.max(1, this.props.aroundLine - LINES / 2 + 1) : 1; let to = this.props.aroundLine ? this.props.aroundLine + LINES / 2 + 1 : LINES + 1; // make sure we try to download `LINES` lines if (from === 1 && to < LINES) { to = LINES; } // request one additional line to define `hasSourcesAfter` to++; return this.safeLoadSources(this.props.component, from, to, this.props.branchLike).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.safeLoadSources( this.props.component, from, firstSourceLine.line - 1, this.props.branchLike ).then( sources => { this.safeLoadIssues( this.props.component, from, firstSourceLine.line - 1, this.props.branchLike ).then( issues => { if (this.mounted) { this.setState(prevState => { const nextIssues = uniqBy( [...issues, ...(prevState.issues || [])], issue => issue.key ); return { issues: nextIssues, issuesByLine: issuesByLine(nextIssues), issueLocationsByLine: locationsByLine(nextIssues), loadingSourcesBefore: false, sources: [...this.computeCoverageStatus(sources), ...(prevState.sources || [])], symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) } }; }); } }, () => { // TODO } ); }, () => { // TODO } ); }; 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.safeLoadSources(this.props.component, fromLine, toLine, this.props.branchLike).then( sources => { this.safeLoadIssues(this.props.component, fromLine, toLine, this.props.branchLike).then( issues => { if (this.mounted) { this.setState(prevState => { const nextIssues = uniqBy( [...(prevState.issues || []), ...issues], issue => issue.key ); return { issues: nextIssues, issuesByLine: issuesByLine(nextIssues), issueLocationsByLine: locationsByLine(nextIssues), hasSourcesAfter: sources.length > LINES, loadingSourcesAfter: false, sources: [ ...(prevState.sources || []), ...this.computeCoverageStatus(sources.slice(0, LINES)) ], symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources.slice(0, LINES)) } }; }); } }, () => { // TODO } ); }, () => { // TODO } ); }; loadDuplications = (line: SourceLine) => { getDuplications({ key: this.props.component, ...getBranchLikeQuery(this.props.branchLike) }).then( r => { if (this.mounted) { this.setState(state => ({ displayDuplications: true, duplications: r.duplications, duplicationsByLine: duplicationsByLine(r.duplications), duplicatedFiles: r.files, linePopup: r.duplications.length === 1 ? { index: 0, line: line.line, name: 'duplications' } : state.linePopup })); } }, () => { // TODO } ); }; handleLinePopupToggle = ({ index, line, name, open }: { index?: number; line: number; name: string; open?: boolean; }) => { this.setState((state: State) => { const samePopup = state.linePopup !== undefined && state.linePopup.name === name && state.linePopup.line === line && state.linePopup.index === index; if (open !== false && !samePopup) { return { linePopup: { index, line, name } }; } else if (open !== true && samePopup) { return { linePopup: undefined }; } return null; }); }; closeLinePopup = () => { this.setState({ linePopup: undefined }); }; 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; }); }; handleSymbolClick = (symbols: string[]) => { this.setState(state => { const shouldDisable = intersection(state.highlightedSymbols, symbols).length > 0; const highlightedSymbols = shouldDisable ? [] : symbols; return { highlightedSymbols }; }); }; handleIssueSelect = (issue: string) => { if (this.props.onIssueSelect) { this.props.onIssueSelect(issue); } else { this.setState({ selectedIssue: issue }); } }; handleIssueUnselect = () => { if (this.props.onIssueUnselect) { this.props.onIssueUnselect(); } else { this.setState({ selectedIssue: undefined }); } }; handleOpenIssues = (line: SourceLine) => { this.setState(state => ({ openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true } })); }; handleCloseIssues = (line: SourceLine) => { this.setState(state => ({ openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false } })); }; handleIssueChange = (issue: Issue) => { this.setState(({ issues = [] }) => { const newIssues = issues.map(candidate => (candidate.key === issue.key ? issue : candidate)); return { issues: newIssues, issuesByLine: issuesByLine(newIssues) }; }); if (this.props.onIssueChange) { this.props.onIssueChange(issue); } }; handleFilterLine = (line: SourceLine) => { const { component } = this.state; const leakPeriodDate = component && component.leakPeriodDate; return leakPeriodDate ? line.scmDate !== undefined && parseDate(line.scmDate) > parseDate(leakPeriodDate) : false; }; renderDuplicationPopup = (index: number, line: number) => { const { component, duplicatedFiles, duplications } = this.state; if (!component || !duplicatedFiles) return <>; const duplication = duplications && duplications[index]; let blocks = (duplication && duplication.blocks) || []; /* eslint-disable no-underscore-dangle */ const inRemovedComponent = blocks.some(b => b._ref === undefined); 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 !== undefined && shouldDisplay; if (b._ref === '1' && !outOfBounds) { foundOne = true; } return isOk; }); /* eslint-enable no-underscore-dangle */ return ( ); }; renderCode(sources: SourceLine[]) { const hasSourcesBefore = sources.length > 0 && sources[0].line > 1; return ( ); } render() { const { component, loading, sources, notAccessible, sourceRemoved } = this.state; if (loading) { return null; } if (this.state.notExist) { return (
{translate('component_viewer.no_component')}
); } if (notAccessible) { return (
{translate('code_viewer.no_source_code_displayed_due_to_security')}
); } if (!component) { return null; } const className = classNames('source-viewer', { 'source-duplications-expanded': this.state.displayDuplications }); return (
(this.node = node)}> {this.state.component && ( )} {sourceRemoved && (
{translate('code_viewer.no_source_code_displayed_due_to_source_removed')}
)} {!sourceRemoved && sources !== undefined && this.renderCode(sources)}
); } } function defaultLoadComponent(key: string, branchLike: BranchLike | undefined) { return Promise.all([ getComponentForSourceViewer({ component: key, ...getBranchLikeQuery(branchLike) }), getComponentData({ component: key, ...getBranchLikeQuery(branchLike) }) ]).then(([component, data]) => ({ ...component, leakPeriodDate: data.leakPeriodDate && parseDate(data.leakPeriodDate) })); } function defaultLoadSources( key: string, from: number | undefined, to: number | undefined, branchLike: BranchLike | undefined ) { return getSources({ key, from, to, ...getBranchLikeQuery(branchLike) }); }