/*
* 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.
*/
import $ from 'jquery';
import moment from 'moment';
import sortBy from 'lodash/sortBy';
import toPairs from 'lodash/toPairs';
import Marionette from 'backbone.marionette';
import Source from './source';
import Issues from '../issue/collections/issues';
import IssueView from '../issue/issue-view';
import HeaderView from './header';
import SCMPopupView from './popups/scm-popup';
import CoveragePopupView from './popups/coverage-popup';
import DuplicationPopupView from './popups/duplication-popup';
import LineActionsPopupView from './popups/line-actions-popup';
import highlightLocations from './helpers/code-with-issue-locations-helper';
import Template from './templates/source-viewer.hbs';
import IssueLocationTemplate from './templates/source-viewer-issue-location.hbs';
import { translateWithParameters } from '../../helpers/l10n';
const HIGHLIGHTED_ROW_CLASS = 'source-line-highlighted';
export default Marionette.LayoutView.extend({
className: 'source-viewer',
template: Template,
issueLocationTemplate: IssueLocationTemplate,
ISSUES_LIMIT: 3000,
LINES_AROUND: 500,
// keep it twice bigger than LINES_AROUND
LINES_LIMIT: 1000,
TOTAL_LINES_LIMIT: 1000,
regions: {
headerRegion: '.source-viewer-header'
},
ui: {
sourceBeforeSpinner: '.js-component-viewer-source-before',
sourceAfterSpinner: '.js-component-viewer-source-after'
},
events () {
return {
'click .sym': 'highlightUsages',
'click .source-line-scm': 'showSCMPopup',
'click .source-line-covered': 'showCoveragePopup',
'click .source-line-partially-covered': 'showCoveragePopup',
'click .source-line-uncovered': 'showCoveragePopup',
'click .source-line-duplications': 'showDuplications',
'click .source-line-duplications-extra': 'showDuplicationPopup',
'click .source-line-with-issues': 'onLineIssuesClick',
'click .source-line-number[data-line-number]': 'onLineNumberClick',
'mouseenter .source-line-filtered .source-line-filtered-container': 'showFilteredTooltip',
'mouseleave .source-line-filtered .source-line-filtered-container': 'hideFilteredTooltip',
'click @ui.sourceBeforeSpinner': 'loadSourceBefore',
'click @ui.sourceAfterSpinner': 'loadSourceAfter'
};
},
initialize () {
if (this.model == null) {
this.model = new Source();
}
this.issues = new Issues();
this.listenTo(this.issues, 'change:severity', this.onIssuesSeverityChange);
this.listenTo(this.issues, 'locations', this.toggleIssueLocations);
this.issueViews = [];
this.highlightedLine = null;
this.listenTo(this, 'loaded', this.onLoaded);
},
renderHeader () {
this.headerRegion.show(new HeaderView({
viewer: this,
model: this.model
}));
},
onRender () {
this.renderHeader();
this.renderIssues();
if (this.model.has('filterLinesFunc')) {
this.filterLines(this.model.get('filterLinesFunc'));
}
this.$('[data-toggle="tooltip"]').tooltip({ container: 'body' });
},
onDestroy () {
this.issueViews.forEach(view => view.destroy());
this.issueViews = [];
this.clearTooltips();
this.unbindScrollEvents();
},
clearTooltips () {
this.$('[data-toggle="tooltip"]').tooltip('destroy');
},
onLoaded () {
this.bindScrollEvents();
},
open (id, options) {
const that = this;
const opts = typeof options === 'object' ? options : {};
const finalize = function () {
that.requestIssues().done(() => {
if (!that.isDestroyed) {
that.render();
that.trigger('loaded');
}
});
};
Object.assign(this.options, { workspace: false, ...opts });
this.model
.clear()
.set(this.model.defaults())
.set({ uuid: id });
this.requestComponent().done(() => {
that.requestSource(opts.aroundLine)
.done(finalize)
.fail(() => {
that.model.set({
source: [
{ line: 0 }
]
});
finalize();
});
});
return this;
},
requestComponent () {
const that = this;
const url = window.baseUrl + '/api/components/app';
const data = { uuid: this.model.id };
return $.ajax({
url,
data,
type: 'GET',
statusCode: {
404 () {
that.model.set({ exist: false });
that.render();
that.trigger('loaded');
}
}
}).done(r => {
that.model.set(r);
that.model.set({ isUnitTest: r.q === 'UTS' });
});
},
linesLimit (aroundLine) {
if (aroundLine) {
return {
from: Math.max(1, aroundLine - this.LINES_AROUND),
to: aroundLine + this.LINES_AROUND
};
}
return { from: 1, to: this.LINES_AROUND };
},
getCoverageStatus (row) {
let status = null;
if (row.lineHits > 0) {
status = 'partially-covered';
}
if (row.lineHits > 0 && row.conditions === row.coveredConditions) {
status = 'covered';
}
if (row.lineHits === 0 || row.coveredConditions === 0) {
status = 'uncovered';
}
return status;
},
requestSource (aroundLine) {
const that = this;
const url = window.baseUrl + '/api/sources/lines';
const data = { uuid: this.model.id, ...this.linesLimit(aroundLine) };
return $.ajax({
url,
data,
statusCode: {
// don't display global error
403: null
}
}).done(r => {
let source = (r.sources || []).slice(0);
if (source.length === 0 || (source.length > 0 && source[0].line === 1)) {
source.unshift({ line: 0 });
}
source = source.map(row => {
return { ...row, coverageStatus: that.getCoverageStatus(row) };
});
const firstLine = source.length > 0 ? source[0].line : null;
const linesRequested = data.to - data.from + 1;
that.model.set({
source,
hasCoverage: that.model.hasCoverage(source),
hasSourceBefore: firstLine > 1,
hasSourceAfter: r.sources.length === linesRequested
});
that.model.checkIfHasDuplications();
}).fail(request => {
if (request.status === 403) {
that.model.set({
source: [],
hasSourceBefore: false,
hasSourceAfter: false,
canSeeCode: false
});
}
});
},
requestDuplications () {
const that = this;
const url = window.baseUrl + '/api/duplications/show';
const options = { uuid: this.model.id };
return $.get(url, options, data => {
const hasDuplications = data.duplications != null;
let duplications = [];
if (hasDuplications) {
duplications = {};
data.duplications.forEach(d => {
d.blocks.forEach(b => {
if (b._ref === '1') {
const lineFrom = b.from;
const lineTo = b.from + b.size - 1;
for (let j = lineFrom; j <= lineTo; j++) {
duplications[j] = true;
}
}
});
});
duplications = toPairs(duplications).map(line => {
return {
line: +line[0],
duplicated: line[1]
};
});
}
that.model.addMeta(duplications);
that.model.addDuplications(data.duplications);
that.model.set({
duplications: data.duplications,
duplicationsParsed: duplications,
duplicationFiles: data.files
});
});
},
requestIssues () {
const that = this;
const options = {
data: {
componentUuids: this.model.id,
f: 'component,componentId,project,subProject,rule,status,resolution,author,assignee,debt,' +
'line,message,severity,creationDate,updateDate,closeDate,tags,comments,attr,actions,' +
'transitions',
additionalFields: '_all',
resolved: false,
s: 'FILE_LINE',
asc: true,
ps: this.ISSUES_LIMIT
}
};
return this.issues.fetch(options).done(() => {
that.addIssuesPerLineMeta(that.issues);
});
},
_sortBySeverity (issues) {
const order = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO'];
return sortBy(issues, issue => order.indexOf(issue.severity));
},
addIssuesPerLineMeta (issues) {
const that = this;
const lines = {};
issues.forEach(issue => {
const line = issue.get('line') || 0;
if (!Array.isArray(lines[line])) {
lines[line] = [];
}
lines[line].push(issue.toJSON());
});
const issuesPerLine = toPairs(lines).map(line => {
return {
line: +line[0],
issues: that._sortBySeverity(line[1])
};
});
this.model.addMeta(issuesPerLine);
this.addIssueLocationsMeta(issues);
},
addIssueLocationsMeta (issues) {
const issueLocations = [];
issues.forEach(issue => {
issue.getLinearLocations().forEach(location => {
const record = issueLocations.find(row => row.line === location.line);
if (record) {
record.issueLocations.push({ from: location.from, to: location.to });
} else {
issueLocations.push({
line: location.line,
issueLocations: [{ from: location.from, to: location.to }]
});
}
});
});
this.model.addMeta(issueLocations);
},
renderIssues () {
this.$('.issue-list').addClass('hidden');
},
renderIssue (issue) {
const issueView = new IssueView({
el: '#issue-' + issue.get('key'),
model: issue
});
this.issueViews.push(issueView);
issueView.render();
},
addIssue (issue) {
const line = issue.get('line') || 0;
const code = this.$(`.source-line-code[data-line-number=${line}]`);
const issueBox = `
`;
code.addClass('has-issues');
let issueList = code.find('.issue-list');
if (issueList.length === 0) {
code.append('
');
issueList = code.find('.issue-list');
}
issueList
.append(issueBox)
.removeClass('hidden');
this.renderIssue(issue);
},
showIssuesForLine (line) {
this.$(`.source-line-code[data-line-number="${line}"]`).find('.issue-list').removeClass('hidden');
const issues = this.issues.filter(issue => (
(issue.get('line') === line) || (!issue.get('line') && !line)
));
issues.forEach(this.renderIssue, this);
},
onIssuesSeverityChange () {
const that = this;
this.addIssuesPerLineMeta(this.issues);
this.$('.source-line-with-issues').each(function () {
const line = +$(this).data('line-number');
const row = that.model.get('source').find(row => row.line === line);
const issue = row.issues[0];
$(this).html(`
`);
});
},
highlightUsages (e) {
const highlighted = $(e.currentTarget).is('.highlighted');
const key = e.currentTarget.className.match(/sym-\d+/);
if (key) {
this.$('.sym.highlighted').removeClass('highlighted');
if (!highlighted) {
this.$('.sym.' + key[0]).addClass('highlighted');
}
}
},
showSCMPopup (e) {
e.stopPropagation();
$('body').click();
const line = +$(e.currentTarget).data('line-number');
const row = this.model.get('source').find(row => row.line === line);
const popup = new SCMPopupView({
triggerEl: $(e.currentTarget),
line: row
});
popup.render();
},
showCoveragePopup (e) {
e.stopPropagation();
$('body').click();
this.clearTooltips();
const line = $(e.currentTarget).data('line-number');
const row = this.model.get('source').find(row => row.line === line);
const url = window.baseUrl + '/api/tests/list';
const options = {
sourceFileId: this.model.id,
sourceFileLineNumber: line,
ps: 1000
};
return $.get(url, options).done(data => {
const popup = new CoveragePopupView({
line: row,
tests: data.tests,
triggerEl: $(e.currentTarget)
});
popup.render();
});
},
showDuplications (e) {
const that = this;
const lineNumber = $(e.currentTarget).closest('.source-line').data('line-number');
this.clearTooltips();
this.requestDuplications().done(() => {
that.render();
that.$el.addClass('source-duplications-expanded');
// immediately show dropdown popup if there is only one duplicated block
if (that.model.get('duplications').length === 1) {
const dupsBlock = that.$(`.source-line[data-line-number=${lineNumber}]`)
.find('.source-line-duplications-extra');
dupsBlock.click();
}
});
},
showDuplicationPopup (e) {
e.stopPropagation();
$('body').click();
this.clearTooltips();
const index = $(e.currentTarget).data('index');
const line = $(e.currentTarget).data('line-number');
let blocks = this.model.get('duplications')[index - 1].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 popup = new DuplicationPopupView({
blocks,
inRemovedComponent,
component: this.model.toJSON(),
files: this.model.get('duplicationFiles'),
triggerEl: $(e.currentTarget)
});
popup.render();
},
onLineIssuesClick (e) {
const line = $(e.currentTarget).data('line-number');
const issuesList = $(e.currentTarget).parent().find('.issue-list');
const areIssuesRendered = issuesList.find('.issue-inner').length > 0;
if (issuesList.is('.hidden')) {
if (areIssuesRendered) {
issuesList.removeClass('hidden');
} else {
this.showIssuesForLine(line);
}
} else {
issuesList.addClass('hidden');
}
},
showLineActionsPopup (e) {
e.stopPropagation();
$('body').click();
const line = $(e.currentTarget).data('line-number');
const popup = new LineActionsPopupView({
line,
triggerEl: $(e.currentTarget),
component: this.model.toJSON()
});
popup.render();
},
onLineNumberClick (e) {
const row = $(e.currentTarget).closest('.source-line');
const line = row.data('line-number');
const highlighted = row.is('.' + HIGHLIGHTED_ROW_CLASS);
if (!highlighted) {
this.highlightLine(line);
this.showLineActionsPopup(e);
} else {
this.removeHighlighting();
}
},
removeHighlighting () {
this.highlightedLine = null;
this.$('.' + HIGHLIGHTED_ROW_CLASS).removeClass(HIGHLIGHTED_ROW_CLASS);
},
highlightLine (line) {
const row = this.$(`.source-line[data-line-number=${line}]`);
this.removeHighlighting();
this.highlightedLine = line;
row.addClass(HIGHLIGHTED_ROW_CLASS);
return this;
},
bindScrollEvents () {
// no op
},
unbindScrollEvents () {
// no op
},
onScroll () {
// no op
},
scrollToLine (line) {
const row = this.$(`.source-line[data-line-number=${line}]`);
if (row.length > 0) {
let p = this.$el.scrollParent();
if (p.is(document) || p.is('body')) {
p = $(window);
}
const pTopOffset = p.offset() != null ? p.offset().top : 0;
const pHeight = p.height();
const goal = row.offset().top - pHeight / 3 - pTopOffset;
p.scrollTop(goal);
}
return this;
},
scrollToFirstLine (line) {
const row = this.$(`.source-line[data-line-number=${line}]`);
if (row.length > 0) {
let p = this.$el.scrollParent();
if (p.is(document) || p.is('body')) {
p = $(window);
}
const pTopOffset = p.offset() != null ? p.offset().top : 0;
const goal = row.offset().top - pTopOffset;
p.scrollTop(goal);
}
return this;
},
scrollToLastLine (line) {
const row = this.$(`.source-line[data-line-number=${line}]`);
if (row.length > 0) {
let p = this.$el.scrollParent();
if (p.is(document) || p.is('body')) {
p = $(window);
}
const pTopOffset = p.offset() != null ? p.offset().top : 0;
const pHeight = p.height();
const goal = row.offset().top - pTopOffset - pHeight + row.height();
p.scrollTop(goal);
}
return this;
},
loadSourceBefore (e) {
e.preventDefault();
this.unbindScrollEvents();
this.$('.js-component-viewer-loading-before').removeClass('hidden');
this.$('.js-component-viewer-source-before').addClass('hidden');
const that = this;
let source = this.model.get('source');
const firstLine = source[0].line;
const url = window.baseUrl + '/api/sources/lines';
const options = {
uuid: this.model.id,
from: Math.max(1, firstLine - this.LINES_AROUND),
to: firstLine - 1
};
return $.get(url, options).done(data => {
source = (data.sources || []).concat(source);
if (source.length > that.TOTAL_LINES_LIMIT + 1) {
source = source.slice(0, that.TOTAL_LINES_LIMIT);
that.model.set({ hasSourceAfter: true });
}
if (source.length === 0 || (source.length > 0 && source[0].line === 1)) {
source.unshift({ line: 0 });
}
source = source.map(row => {
return { ...row, coverageStatus: that.getCoverageStatus(row) };
});
that.model.set({
source,
hasCoverage: that.model.hasCoverage(source),
hasSourceBefore: data.sources.length === that.LINES_AROUND && source.length > 0 && source[0].line > 0
});
that.addIssuesPerLineMeta(that.issues);
if (that.model.has('duplications')) {
that.model.addDuplications(that.model.get('duplications'));
that.model.addMeta(that.model.get('duplicationsParsed'));
}
that.model.checkIfHasDuplications();
that.render();
that.scrollToFirstLine(firstLine);
if (that.model.get('hasSourceBefore') || that.model.get('hasSourceAfter')) {
that.bindScrollEvents();
}
});
},
loadSourceAfter (e) {
e.preventDefault();
this.unbindScrollEvents();
this.$('.js-component-viewer-loading-after').removeClass('hidden');
this.$('.js-component-viewer-source-after').addClass('hidden');
const that = this;
let source = this.model.get('source');
const lastLine = source[source.length - 1].line;
const url = window.baseUrl + '/api/sources/lines';
const options = {
uuid: this.model.id,
from: lastLine + 1,
to: lastLine + this.LINES_AROUND
};
return $.get(url, options).done(data => {
source = source.concat(data.sources);
if (source.length > that.TOTAL_LINES_LIMIT + 1) {
source = source.slice(source.length - that.TOTAL_LINES_LIMIT);
that.model.set({ hasSourceBefore: true });
}
source = source.map(row => {
return { ...row, coverageStatus: that.getCoverageStatus(row) };
});
that.model.set({
source,
hasCoverage: that.model.hasCoverage(source),
hasSourceAfter: data.sources.length === that.LINES_AROUND
});
that.addIssuesPerLineMeta(that.issues);
if (that.model.has('duplications')) {
that.model.addDuplications(that.model.get('duplications'));
that.model.addMeta(that.model.get('duplicationsParsed'));
}
that.model.checkIfHasDuplications();
that.render();
that.scrollToLastLine(lastLine);
if (that.model.get('hasSourceBefore') || that.model.get('hasSourceAfter')) {
that.bindScrollEvents();
}
}).fail(() => {
that.model.set({
hasSourceAfter: false
});
that.render();
if (that.model.get('hasSourceBefore') || that.model.get('hasSourceAfter')) {
that.bindScrollEvents();
}
});
},
filterLines (func) {
const lines = this.model.get('source');
const $lines = this.$('.source-line');
this.model.set('filterLinesFunc', func);
lines.forEach((line, idx) => {
const $line = $($lines[idx]);
const filtered = func(line) && line.line > 0;
$line.toggleClass('source-line-shadowed', !filtered);
$line.toggleClass('source-line-filtered', filtered);
});
},
filterLinesByDate (date, label) {
const sinceDate = moment(date).toDate();
this.sinceLabel = label;
this.filterLines(line => {
const scmDate = moment(line.scmDate).toDate();
return scmDate >= sinceDate;
});
},
showFilteredTooltip (e) {
$(e.currentTarget).tooltip({
container: 'body',
placement: 'right',
title: translateWithParameters('source_viewer.tooltip.new_code', this.sinceLabel),
trigger: 'manual'
}).tooltip('show');
},
hideFilteredTooltip (e) {
$(e.currentTarget).tooltip('destroy');
},
toggleIssueLocations (issue) {
if (this.locationsShowFor === issue) {
this.hideIssueLocations();
} else {
this.hideIssueLocations();
this.showIssueLocations(issue);
}
},
showIssueLocations (issue) {
this.locationsShowFor = issue;
const primaryLocation = {
msg: issue.get('message'),
textRange: issue.get('textRange')
};
let _locations = [primaryLocation];
issue.get('flows').forEach(flow => {
const flowLocationsCount = Array.isArray(flow.locations) ? flow.locations.length : 0;
const flowLocations = flow.locations.map((location, index) => {
const _location = { ...location };
if (flowLocationsCount > 1) {
Object.assign(_location, { index: flowLocationsCount - index });
}
return _location;
});
_locations = [].concat(_locations, flowLocations);
});
_locations.forEach(this.showIssueLocation, this);
},
showIssueLocation (location, index) {
if (location && location.textRange) {
const line = location.textRange.startLine;
const row = this.$(`.source-line-code[data-line-number="${line}"]`);
if (index > 0 && location.msg) {
// render location marker only for
// secondary locations and execution flows
// and only if message is not empty
const renderedFlowLocation = this.renderIssueLocation(location);
row.find('.source-line-issue-locations').prepend(renderedFlowLocation);
}
this.highlightIssueLocationInCode(location);
}
},
renderIssueLocation (location) {
location.msg = location.msg ? location.msg : ' ';
return this.issueLocationTemplate(location);
},
highlightIssueLocationInCode (location) {
for (let line = location.textRange.startLine; line <= location.textRange.endLine; line++) {
const row = this.$(`.source-line-code[data-line-number="${line}"]`);
// get location for the current line
const from = line === location.textRange.startLine ? location.textRange.startOffset : 0;
const to = line === location.textRange.endLine ? location.textRange.endOffset : 999999;
const _location = { from, to };
// mark issue location in the source code
const codeEl = row.find('.source-line-code-inner > pre');
const code = codeEl.html();
const newCode = highlightLocations(code, [_location], 'source-line-code-secondary-issue');
codeEl.html(newCode);
}
},
hideIssueLocations () {
this.locationsShowFor = null;
this.$('.source-line-issue-locations').empty();
this.$('.source-line-code-secondary-issue').removeClass('source-line-code-secondary-issue');
}
});