/*
* SonarQube :: Web
* Copyright (C) 2009-2016 SonarSource SA
* mailto:contact 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 _ from 'underscore';
import moment from 'moment';
import Backbone from 'backbone';
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';
var HIGHLIGHTED_ROW_CLASS = 'source-line-highlighted';
export default Marionette.LayoutView.extend({
className: 'source-viewer',
template: Template,
issueLocationTemplate: IssueLocationTemplate,
ISSUES_LIMIT: 3000,
LINES_LIMIT: 1000,
TOTAL_LINES_LIMIT: 3000,
LINES_AROUND: 500,
regions: {
headerRegion: '.source-viewer-header'
},
ui: {
sourceBeforeSpinner: '.js-component-viewer-source-before',
sourceAfterSpinner: '.js-component-viewer-source-after'
},
events: function () {
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'
};
},
initialize: function () {
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.loadSourceBeforeThrottled = _.throttle(this.loadSourceBefore, 1000);
this.loadSourceAfterThrottled = _.throttle(this.loadSourceAfter, 1000);
this.highlightedLine = null;
this.listenTo(this, 'loaded', this.onLoaded);
},
renderHeader: function () {
this.headerRegion.show(new HeaderView({
viewer: this,
model: this.model
}));
},
onRender: function () {
this.renderHeader();
this.renderIssues();
if (this.model.has('filterLinesFunc')) {
this.filterLines(this.model.get('filterLinesFunc'));
}
this.$('[data-toggle="tooltip"]').tooltip({ container: 'body' });
},
onDestroy: function () {
this.issueViews.forEach(function (view) {
return view.destroy();
});
this.issueViews = [];
this.clearTooltips();
},
clearTooltips: function () {
this.$('[data-toggle="tooltip"]').tooltip('destroy');
},
onLoaded: function () {
this.bindScrollEvents();
},
open: function (id, options) {
var that = this,
opts = typeof options === 'object' ? options : {},
finalize = function () {
that.requestIssues().done(function () {
that.render();
that.trigger('loaded');
});
};
_.extend(this.options, _.defaults(opts, { workspace: false }));
this.model
.clear()
.set(_.result(this.model, 'defaults'))
.set({ uuid: id });
this.requestComponent().done(function () {
that.requestSource()
.done(finalize)
.fail(function () {
that.model.set({
source: [
{ line: 0 }
]
});
finalize();
});
});
return this;
},
requestComponent: function () {
var that = this,
url = baseUrl + '/api/components/app',
data = { uuid: this.model.id };
return $.ajax({
type: 'GET',
url: url,
data: data,
statusCode: {
404: function () {
that.model.set({ exist: false });
that.render();
that.trigger('loaded');
}
}
}).done(function (r) {
that.model.set(r);
that.model.set({ isUnitTest: r.q === 'UTS' });
});
},
linesLimit: function () {
return {
from: 1,
to: this.LINES_LIMIT
};
},
getUTCoverageStatus: function (row) {
var status = null;
if (row.utLineHits > 0) {
status = 'partially-covered';
}
if (row.utLineHits > 0 && row.utConditions === row.utCoveredConditions) {
status = 'covered';
}
if (row.utLineHits === 0 || row.utCoveredConditions === 0) {
status = 'uncovered';
}
return status;
},
getItCoverageStatus: function (row) {
var status = null;
if (row.itLineHits > 0) {
status = 'partially-covered';
}
if (row.itLineHits > 0 && row.itConditions === row.itCoveredConditions) {
status = 'covered';
}
if (row.itLineHits === 0 || row.itCoveredConditions === 0) {
status = 'uncovered';
}
return status;
},
requestSource: function () {
var that = this,
url = baseUrl + '/api/sources/lines',
options = _.extend({ uuid: this.model.id }, this.linesLimit());
return $.get(url, options).done(function (data) {
var source = (data.sources || []).slice(0);
if (source.length === 0 || (source.length > 0 && _.first(source).line === 1)) {
source.unshift({ line: 0 });
}
source = source.map(function (row) {
return _.extend(row, {
utCoverageStatus: that.getUTCoverageStatus(row),
itCoverageStatus: that.getItCoverageStatus(row)
});
});
var firstLine = _.first(source).line,
linesRequested = options.to - options.from + 1;
that.model.set({
source: source,
hasUTCoverage: that.model.hasUTCoverage(source),
hasITCoverage: that.model.hasITCoverage(source),
hasSourceBefore: firstLine > 1,
hasSourceAfter: data.sources.length === linesRequested
});
that.model.checkIfHasDuplications();
}).fail(function (request) {
if (request.status === 403) {
that.model.set({
source: [],
hasSourceBefore: false,
hasSourceAfter: false,
canSeeCode: false
});
}
});
},
requestDuplications: function () {
var that = this,
url = baseUrl + '/api/duplications/show',
options = { uuid: this.model.id };
return $.get(url, options, function (data) {
var hasDuplications = (data != null) && (data.duplications != null),
duplications = [];
if (hasDuplications) {
duplications = {};
data.duplications.forEach(function (d) {
d.blocks.forEach(function (b) {
if (b._ref === '1') {
var lineFrom = b.from,
lineTo = b.from + b.size - 1;
for (var j = lineFrom; j <= lineTo; j++) {
duplications[j] = true;
}
}
});
});
duplications = _.pairs(duplications).map(function (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: function () {
var that = this,
options = {
data: {
componentUuids: this.model.id,
f: 'component,componentId,project,subProject,rule,status,resolution,author,reporter,assignee,debt,' +
'line,message,severity,actionPlan,creationDate,updateDate,closeDate,tags,comments,attr,actions,' +
'transitions,actionPlanName',
additionalFields: '_all',
resolved: false,
s: 'FILE_LINE',
asc: true,
ps: this.ISSUES_LIMIT
}
};
return this.issues.fetch(options).done(function () {
that.addIssuesPerLineMeta(that.issues);
});
},
_sortBySeverity: function (issues) {
var order = ['BLOCKER', 'CRITICAL', 'MAJOR', 'MINOR', 'INFO'];
return _.sortBy(issues, function (issue) {
return order.indexOf(issue.severity);
});
},
addIssuesPerLineMeta: function (issues) {
var that = this,
lines = {};
issues.forEach(function (issue) {
var line = issue.get('line') || 0;
if (!_.isArray(lines[line])) {
lines[line] = [];
}
lines[line].push(issue.toJSON());
});
var issuesPerLine = _.pairs(lines).map(function (line) {
return {
line: +line[0],
issues: that._sortBySeverity(line[1])
};
});
this.model.addMeta(issuesPerLine);
this.addIssueLocationsMeta(issues);
},
addIssueLocationsMeta: function (issues) {
var issueLocations = [];
issues.forEach(function (issue) {
issue.getLinearLocations().forEach(function (location) {
var record = _.findWhere(issueLocations, { 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: function () {
this.$('.issue-list').addClass('hidden');
},
renderIssue: function (issue) {
var issueView = new IssueView({
el: '#issue-' + issue.get('key'),
model: issue
});
this.issueViews.push(issueView);
issueView.render();
},
addIssue: function (issue) {
var line = issue.get('line') || 0,
code = this.$('.source-line-code[data-line-number=' + line + ']'),
issueBox = '
';
code.addClass('has-issues');
var 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: function (line) {
this.$('.source-line-code[data-line-number="' + line + '"]').find('.issue-list').removeClass('hidden');
var issues = this.issues.filter(function (issue) {
return (issue.get('line') === line) || (!issue.get('line') && !line);
});
issues.forEach(this.renderIssue, this);
},
onIssuesSeverityChange: function () {
var that = this;
this.addIssuesPerLineMeta(this.issues);
this.$('.source-line-with-issues').each(function () {
var line = +$(this).data('line-number'),
row = _.findWhere(that.model.get('source'), { line: line }),
issue = _.first(row.issues);
$(this).html('
');
});
},
highlightUsages: function (e) {
var highlighted = $(e.currentTarget).is('.highlighted'),
key = e.currentTarget.className.split(/\s+/)[0];
this.$('.sym.highlighted').removeClass('highlighted');
if (!highlighted) {
this.$('.sym.' + key).addClass('highlighted');
}
},
showSCMPopup: function (e) {
e.stopPropagation();
$('body').click();
var line = +$(e.currentTarget).data('line-number'),
row = _.findWhere(this.model.get('source'), { line: line }),
popup = new SCMPopupView({
triggerEl: $(e.currentTarget),
model: new Backbone.Model(row)
});
popup.render();
},
showCoveragePopup: function (e) {
e.stopPropagation();
$('body').click();
this.clearTooltips();
var line = $(e.currentTarget).data('line-number'),
row = _.findWhere(this.model.get('source'), { line: line }),
url = baseUrl + '/api/tests/list',
options = {
sourceFileId: this.model.id,
sourceFileLineNumber: line,
ps: 1000
};
return $.get(url, options).done(function (data) {
var popup = new CoveragePopupView({
collection: new Backbone.Collection(data.tests),
row: row,
tests: $(e.currentTarget).data('tests'),
triggerEl: $(e.currentTarget)
});
popup.render();
});
},
showDuplications: function (e) {
var that = this,
lineNumber = $(e.currentTarget).closest('.source-line').data('line-number');
this.clearTooltips();
this.requestDuplications().done(function () {
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) {
var dupsBlock = that.$('.source-line[data-line-number=' + lineNumber + ']')
.find('.source-line-duplications-extra');
dupsBlock.click();
}
});
},
showDuplicationPopup: function (e) {
e.stopPropagation();
$('body').click();
this.clearTooltips();
var index = $(e.currentTarget).data('index'),
line = $(e.currentTarget).data('line-number'),
blocks = this.model.get('duplications')[index - 1].blocks,
inRemovedComponent = _.some(blocks, function (b) {
return b._ref == null;
}),
foundOne = false;
blocks = _.filter(blocks, function (b) {
var outOfBounds = b.from > line || b.from + b.size < line,
currentFile = b._ref === '1',
shouldDisplayForCurrentFile = outOfBounds || foundOne,
shouldDisplay = !currentFile || (currentFile && shouldDisplayForCurrentFile),
isOk = (b._ref != null) && shouldDisplay;
if (b._ref === '1' && !outOfBounds) {
foundOne = true;
}
return isOk;
});
var popup = new DuplicationPopupView({
triggerEl: $(e.currentTarget),
model: this.model,
inRemovedComponent: inRemovedComponent,
collection: new Backbone.Collection(blocks)
});
popup.render();
},
onLineIssuesClick: function (e) {
var line = $(e.currentTarget).data('line-number'),
issuesList = $(e.currentTarget).parent().find('.issue-list'),
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: function (e) {
e.stopPropagation();
$('body').click();
var that = this,
line = $(e.currentTarget).data('line-number'),
popup = new LineActionsPopupView({
triggerEl: $(e.currentTarget),
model: this.model,
line: line,
row: $(e.currentTarget).closest('.source-line')
});
popup.on('onManualIssueAdded', function (issue) {
that.addIssue(issue);
});
popup.render();
},
onLineNumberClick: function (e) {
var row = $(e.currentTarget).closest('.source-line'),
line = row.data('line-number'),
highlighted = row.is('.' + HIGHLIGHTED_ROW_CLASS);
if (!highlighted) {
this.highlightLine(line);
this.showLineActionsPopup(e);
} else {
this.removeHighlighting();
}
},
removeHighlighting: function () {
this.highlightedLine = null;
this.$('.' + HIGHLIGHTED_ROW_CLASS).removeClass(HIGHLIGHTED_ROW_CLASS);
},
highlightLine: function (line) {
var row = this.$('.source-line[data-line-number=' + line + ']');
this.removeHighlighting();
this.highlightedLine = line;
row.addClass(HIGHLIGHTED_ROW_CLASS);
return this;
},
bindScrollEvents: function () {
var that = this;
this.$el.scrollParent().on('scroll.source-viewer', function () {
that.onScroll();
});
},
unbindScrollEvents: function () {
this.$el.scrollParent().off('scroll.source-viewer');
},
onScroll: function () {
var p = this.$el.scrollParent();
if (p.is(document)) {
p = $(window);
}
var pTopOffset = p.offset() != null ? p.offset().top : 0,
pPosition = p.scrollTop() + pTopOffset;
if (this.model.get('hasSourceBefore') && (pPosition <= this.ui.sourceBeforeSpinner.offset().top)) {
this.loadSourceBeforeThrottled();
}
if (this.model.get('hasSourceAfter') && (pPosition + p.height() >= this.ui.sourceAfterSpinner.offset().top)) {
return this.loadSourceAfterThrottled();
}
},
scrollToLine: function (line) {
var row = this.$('.source-line[data-line-number=' + line + ']');
if (row.length > 0) {
var p = this.$el.scrollParent();
if (p.is(document)) {
p = $(window);
}
var pTopOffset = p.offset() != null ? p.offset().top : 0,
pHeight = p.height(),
goal = row.offset().top - pHeight / 3 - pTopOffset;
p.scrollTop(goal);
}
return this;
},
scrollToFirstLine: function (line) {
var row = this.$('.source-line[data-line-number=' + line + ']');
if (row.length > 0) {
var p = this.$el.scrollParent();
if (p.is(document)) {
p = $(window);
}
var pTopOffset = p.offset() != null ? p.offset().top : 0,
goal = row.offset().top - pTopOffset;
p.scrollTop(goal);
}
return this;
},
scrollToLastLine: function (line) {
var row = this.$('.source-line[data-line-number=' + line + ']');
if (row.length > 0) {
var p = this.$el.scrollParent();
if (p.is(document)) {
p = $(window);
}
var pTopOffset = p.offset() != null ? p.offset().top : 0,
pHeight = p.height(),
goal = row.offset().top - pTopOffset - pHeight + row.height();
p.scrollTop(goal);
}
return this;
},
loadSourceBefore: function () {
this.unbindScrollEvents();
var that = this,
source = this.model.get('source'),
firstLine = _.first(source).line,
url = baseUrl + '/api/sources/lines',
options = {
uuid: this.model.id,
from: firstLine - this.LINES_AROUND,
to: firstLine - 1
};
return $.get(url, options).done(function (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 && _.first(source).line === 1)) {
source.unshift({ line: 0 });
}
source = source.map(function (row) {
return _.extend(row, {
utCoverageStatus: that.getUTCoverageStatus(row),
itCoverageStatus: that.getItCoverageStatus(row)
});
});
that.model.set({
source: source,
hasUTCoverage: that.model.hasUTCoverage(source),
hasITCoverage: that.model.hasITCoverage(source),
hasSourceBefore: (data.sources.length === that.LINES_AROUND) && (_.first(source).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: function () {
this.unbindScrollEvents();
var that = this,
source = this.model.get('source'),
lastLine = _.last(source).line,
url = baseUrl + '/api/sources/lines',
options = {
uuid: this.model.id,
from: lastLine + 1,
to: lastLine + this.LINES_AROUND
};
return $.get(url, options).done(function (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(function (row) {
return _.extend(row, {
utCoverageStatus: that.getUTCoverageStatus(row),
itCoverageStatus: that.getItCoverageStatus(row)
});
});
that.model.set({
source: source,
hasUTCoverage: that.model.hasUTCoverage(source),
hasITCoverage: that.model.hasITCoverage(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(function () {
that.model.set({
hasSourceAfter: false
});
that.render();
if (that.model.get('hasSourceBefore') || that.model.get('hasSourceAfter')) {
that.bindScrollEvents();
}
});
},
filterLines: function (func) {
var lines = this.model.get('source'),
$lines = this.$('.source-line');
this.model.set('filterLinesFunc', func);
lines.forEach(function (line, idx) {
var $line = $($lines[idx]),
filtered = func(line) && line.line > 0;
$line.toggleClass('source-line-shadowed', !filtered);
$line.toggleClass('source-line-filtered', filtered);
});
},
filterLinesByDate: function (date, label) {
var sinceDate = moment(date).toDate();
this.sinceLabel = label;
this.filterLines(function (line) {
var scmDate = moment(line.scmDate).toDate();
return scmDate >= sinceDate;
});
},
showFilteredTooltip: function (e) {
$(e.currentTarget).tooltip({
container: 'body',
placement: 'right',
title: window.tp('source_viewer.tooltip.new_code', this.sinceLabel),
trigger: 'manual'
}).tooltip('show');
},
hideFilteredTooltip: function (e) {
$(e.currentTarget).tooltip('destroy');
},
toggleIssueLocations: function (issue) {
if (this.locationsShowFor === issue) {
this.hideIssueLocations();
} else {
this.hideIssueLocations();
this.showIssueLocations(issue);
}
},
showIssueLocations: function (issue) {
this.locationsShowFor = issue;
var primaryLocation = {
msg: issue.get('message'),
textRange: issue.get('textRange')
},
_locations = [primaryLocation];
issue.get('flows').forEach(function (flow) {
var flowLocationsCount = _.size(flow.locations);
var flowLocations = flow.locations.map(function (location, index) {
var _location = _.extend({}, location);
if (flowLocationsCount > 1) {
_.extend(_location, { index: flowLocationsCount - index });
}
return _location;
});
_locations = [].concat(_locations, flowLocations);
});
_locations.forEach(this.showIssueLocation, this);
},
showIssueLocation: function (location, index) {
if (location && location.textRange) {
var line = location.textRange.startLine,
row = this.$('.source-line-code[data-line-number="' + line + '"]');
if (index > 0 && _.size(location.msg)) {
// render location marker only for
// secondary locations and execution flows
// and only if message is not empty
var renderedFlowLocation = this.renderIssueLocation(location);
row.find('.source-line-issue-locations').prepend(renderedFlowLocation);
}
this.highlightIssueLocationInCode(location);
}
},
renderIssueLocation: function (location) {
location.msg = location.msg ? location.msg : ' ';
return this.issueLocationTemplate(location);
},
highlightIssueLocationInCode: function (location) {
for (var line = location.textRange.startLine; line <= location.textRange.endLine; line++) {
var row = this.$('.source-line-code[data-line-number="' + line + '"]');
// get location for the current line
var from = line === location.textRange.startLine ? location.textRange.startOffset : 0,
to = line === location.textRange.endLine ? location.textRange.endOffset : 999999,
_location = { from: from, to: to };
// mark issue location in the source code
var codeEl = row.find('.source-line-code-inner > pre'),
code = codeEl.html(),
newCode = highlightLocations(code, [_location], 'source-line-code-secondary-issue');
codeEl.html(newCode);
}
},
hideIssueLocations: function () {
this.locationsShowFor = null;
this.$('.source-line-issue-locations').empty();
this.$('.source-line-code-secondary-issue').removeClass('source-line-code-secondary-issue');
}
});