]> source.dussan.org Git - sonarqube.git/commitdiff
refactor source viewer (#1705)
authorStas Vilchik <stas-vilchik@users.noreply.github.com>
Thu, 2 Mar 2017 12:18:27 +0000 (13:18 +0100)
committerGitHub <noreply@github.com>
Thu, 2 Mar 2017 12:18:27 +0000 (13:18 +0100)
72 files changed:
server/sonar-server/src/main/java/org/sonar/server/component/ws/AppAction.java
server/sonar-server/src/main/resources/org/sonar/server/component/ws/app-example.json
server/sonar-server/src/test/java/org/sonar/server/component/ws/ComponentsWsTest.java
server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app.json
server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_measures.json
server/sonar-server/src/test/resources/org/sonar/server/component/ws/AppActionTest/app_with_ut_measure.json
server/sonar-web/.eslintrc
server/sonar-web/src/main/js/api/components.js
server/sonar-web/src/main/js/api/issues.js
server/sonar-web/src/main/js/apps/code/components/App.js
server/sonar-web/src/main/js/apps/code/components/ComponentPin.js
server/sonar-web/src/main/js/apps/component-measures/components/bubbleChart/BubbleChart.js
server/sonar-web/src/main/js/apps/component-measures/details/drilldown/ListView.js
server/sonar-web/src/main/js/apps/component-measures/details/drilldown/TreeView.js
server/sonar-web/src/main/js/apps/component-measures/details/treemap/MeasureTreemap.js
server/sonar-web/src/main/js/apps/component/components/App.js
server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js [deleted file]
server/sonar-web/src/main/js/apps/issues/component-viewer/main.js
server/sonar-web/src/main/js/apps/issues/controller.js
server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs [deleted file]
server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs [deleted file]
server/sonar-web/src/main/js/apps/issues/workspace-list-item-view.js
server/sonar-web/src/main/js/apps/issues/workspace-list-view.js
server/sonar-web/src/main/js/apps/overview/components/App.js
server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/types.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/popup.js
server/sonar-web/src/main/js/components/issue/ConnectedIssue.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/Issue.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/issue/issue-view.js
server/sonar-web/src/main/js/components/issue/templates/issue.hbs
server/sonar-web/src/main/js/components/issue/types.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/shared/WithStore.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js [deleted file]
server/sonar-web/src/main/js/components/source-viewer/main.js
server/sonar-web/src/main/js/components/source-viewer/measures-overlay.js
server/sonar-web/src/main/js/components/source-viewer/more-actions.js
server/sonar-web/src/main/js/components/source-viewer/popups/coverage-popup.js
server/sonar-web/src/main/js/components/source-viewer/popups/duplication-popup.js
server/sonar-web/src/main/js/components/source-viewer/popups/line-actions-popup.js
server/sonar-web/src/main/js/components/source-viewer/popups/scm-popup.js
server/sonar-web/src/main/js/components/source-viewer/source.js
server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-coverage-popup.hbs
server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-duplication-popup.hbs
server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-header.hbs
server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-measures.hbs
server/sonar-web/src/main/js/components/source-viewer/templates/source-viewer-scm-popup.hbs
server/sonar-web/src/main/js/components/workspace/main.js
server/sonar-web/src/main/js/components/workspace/models/item.js
server/sonar-web/src/main/js/components/workspace/models/items.js
server/sonar-web/src/main/js/components/workspace/views/viewer-view.js
server/sonar-web/src/main/js/helpers/issues.js [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/request.js
server/sonar-web/src/main/js/store/favorites/duck.js
server/sonar-web/src/main/js/store/issues/duck.js [new file with mode: 0644]
server/sonar-web/src/main/js/store/rootReducer.js
server/sonar-web/src/main/less/components/issues.less
server/sonar-web/src/main/less/components/source.less
server/sonar-web/src/main/less/pages/issues.less
server/sonar-web/src/main/less/sonar-colorizer.less

index 73fc5366f6e0444b2658dcc5552778fc411cecb0..fa07c2e89207341ad928091aa230707a42cfded5 100644 (file)
@@ -44,14 +44,17 @@ import org.sonar.db.metric.MetricDto;
 import org.sonar.db.property.PropertyDto;
 import org.sonar.db.property.PropertyQuery;
 import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.component.ComponentFinder.ParamNames;
 import org.sonar.server.user.UserSession;
 
 import static com.google.common.collect.Lists.newArrayList;
 import static org.sonar.core.util.Uuids.UUID_EXAMPLE_01;
+import static org.sonar.server.ws.KeyExamples.KEY_PROJECT_EXAMPLE_001;
 
 public class AppAction implements RequestHandler {
 
   private static final String PARAM_COMPONENT_ID = "componentId";
+  private static final String PARAM_COMPONENT = "component";
   private static final String PARAM_PERIOD = "period";
   static final List<String> METRIC_KEYS = newArrayList(CoreMetrics.LINES_KEY, CoreMetrics.VIOLATIONS_KEY,
     CoreMetrics.COVERAGE_KEY, CoreMetrics.DUPLICATED_LINES_DENSITY_KEY, CoreMetrics.TESTS_KEY,
@@ -79,31 +82,40 @@ public class AppAction implements RequestHandler {
 
     action
       .createParam(PARAM_COMPONENT_ID)
-      .setRequired(true)
       .setDescription("Component ID")
+      .setDeprecatedSince("6.4")
       .setDeprecatedKey("uuid", "6.4")
       .setExampleValue(UUID_EXAMPLE_01);
 
+    action.createParam(PARAM_COMPONENT)
+      .setDescription("Component key")
+      .setExampleValue(KEY_PROJECT_EXAMPLE_001)
+      .setSince("6.4");
+
     action
       .createParam(PARAM_PERIOD)
       .setDescription("User leak Period in order to get differential measures")
+      .setDeprecatedSince("6.4")
       .setPossibleValues(1);
   }
 
   @Override
   public void handle(Request request, Response response) {
-    try (DbSession session = dbClient.openSession(false);
-      JsonWriter json = response.newJsonWriter()) {
-      json.beginObject();
-      String componentUuid = request.mandatoryParam(PARAM_COMPONENT_ID);
-      ComponentDto component = componentFinder.getByUuid(session, componentUuid);
+    try (DbSession session = dbClient.openSession(false)) {
+      ComponentDto component = componentFinder.getByUuidOrKey(session,
+        request.param(PARAM_COMPONENT_ID),
+        request.param(PARAM_COMPONENT),
+        ParamNames.COMPONENT_ID_AND_COMPONENT);
       userSession.checkComponentPermission(UserRole.USER, component);
 
+      JsonWriter json = response.newJsonWriter();
+      json.beginObject();
       Map<String, MeasureDto> measuresByMetricKey = measuresByMetricKey(component, session);
       appendComponent(json, component, userSession, session);
       appendPermissions(json, component, userSession);
       appendMeasures(json, measuresByMetricKey);
       json.endObject();
+      json.close();
     }
   }
 
@@ -138,7 +150,7 @@ public class AppAction implements RequestHandler {
 
   private static void appendPermissions(JsonWriter json, ComponentDto component, UserSession userSession) {
     boolean hasBrowsePermission = userSession.hasComponentPermission(UserRole.USER, component);
-    json.prop("canMarkAsFavourite", userSession.isLoggedIn() && hasBrowsePermission);
+    json.prop("canMarkAsFavorite", userSession.isLoggedIn() && hasBrowsePermission);
   }
 
   private static void appendMeasures(JsonWriter json, Map<String, MeasureDto> measuresByMetricKey) {
index 8863fa6535d1de822fae8a6c3fb739b9bc4e143a..d06748ba5e99525ee11ad2e61cfc00ae1271f575 100644 (file)
@@ -7,7 +7,7 @@
   "project": "com.sonarsource:java-markdown",
   "projectName": "Java Markdown",
   "fav": false,
-  "canMarkAsFavourite": true,
+  "canMarkAsFavorite": true,
   "canCreateManualIssue": true,
   "measures": {
     "lines": "786",
index ce42f92b41ee2b1ebb1630ee9708fafd73e405d5..b1d32d8d8cf879f34b6140b9c0bade97f56dddae 100644 (file)
@@ -83,6 +83,6 @@ public class ComponentsWsTest {
     assertThat(action.isInternal()).isTrue();
     assertThat(action.isPost()).isFalse();
     assertThat(action.handler()).isNotNull();
-    assertThat(action.params()).hasSize(2);
+    assertThat(action.params()).hasSize(3);
   }
 }
index f7f2947c1894c8dd414e4299c1b8f1a8664b2b1c..e3554e20775632b7da79f8ca9b56be8b4cb11f7c 100644 (file)
@@ -10,6 +10,6 @@
   "project": "org.sonarsource.sonarqube:sonarqube",
   "projectName": "SonarQube",
   "fav": false,
-  "canMarkAsFavourite": true,
+  "canMarkAsFavorite": true,
   "measures": {}
 }
index f50715b4b2c3f1cfd19a7c899515587be05d4bc9..64548fda18fd9b5bac4a2b62535af6bf7a63d598 100644 (file)
@@ -10,7 +10,7 @@
   "project": "org.sonarsource.sonarqube:sonarqube",
   "projectName": "SonarQube",
   "fav": false,
-  "canMarkAsFavourite": true,
+  "canMarkAsFavorite": true,
   "measures": {
     "lines": "200.0",
     "coverage": "95.4",
index 59b6db6325a0eab5b0fe92c301c398ec37434f13..f906f7dccbf4c10e9573726e9ab20ace57ea47dd 100644 (file)
@@ -10,7 +10,7 @@
   "project": "org.sonarsource.sonarqube:sonarqube",
   "projectName": "SonarQube",
   "fav": false,
-  "canMarkAsFavourite": true,
+  "canMarkAsFavorite": true,
   "measures": {
     "coverage": "95.4"
   }
index 6632e18d8e4ea27c465dbf814a6cea5252887b3c..be74c6cf9b4317017a58d30ca40d82edcd90acb5 100644 (file)
@@ -13,7 +13,8 @@
   "globals": {
     "key": true,
     "d3": true,
-    "baseUrl": true
+    "baseUrl": true,
+    "SyntheticInputEvent": true
   },
 
   "parser": "babel-eslint",
index 07ca4204d0bcd520ca5974f38361d5f39a97c368..c09192522e35efffece69edbad6cf8ce97be5bf9 100644 (file)
@@ -140,3 +140,26 @@ export function bulkChangeKey (project: string, from: string, to: string, dryRun
 export const getSuggestions = (query: string): Promise<Object> => (
     getJSON('/api/components/suggestions', { s: query })
 );
+
+export const getComponentForSourceViewer = (component: string): Promise<*> => (
+  getJSON('/api/components/app', { component })
+);
+
+export const getSources = (component: string, from?: number, to?: number): Promise<Array<*>> => {
+  const data: Object = { key: component };
+  if (from) {
+    Object.assign(data, { from });
+  }
+  if (to) {
+    Object.assign(data, { to });
+  }
+  return getJSON('/api/sources/lines', data).then(r => r.sources);
+};
+
+export const getDuplications = (component: string): Promise<*> => (
+  getJSON('/api/duplications/show', { key: component })
+);
+
+export const getTests = (component: string, line: number | string): Promise<*> => (
+  getJSON('/api/tests/list', { sourceFileKey: component, sourceFileLineNumber: line }).then(r => r.tests)
+);
index adcb3a1626be5f6335737c0d951930291bc21fc4..75cbdec6e2d3a10cc01260ea719dd6fa04f767f2 100644 (file)
 // @flow
 import { getJSON, post } from '../helpers/request';
 
-export const searchIssues = (query: {}) => (
+type IssuesResponse = {
+  components?: Array<*>,
+  debtTotal?: number,
+  facets: Array<*>,
+  issues: Array<*>,
+  paging: {
+    pageIndex: number,
+    pageSize: number,
+    total: number
+  },
+  rules?: Array<*>,
+  users?: Array<*>
+};
+
+export const searchIssues = (query: {}): Promise<IssuesResponse> => (
     getJSON('/api/issues/search', query)
 );
 
@@ -52,10 +66,10 @@ export function getTags (query: {}): Promise<*> {
 
 export function extractAssignees (
     facet: Array<{ val: string }>,
-    response: { users: Array<{ login: string }> }
+    response: IssuesResponse
 ) {
   return facet.map(item => {
-    const user = response.users.find(user => user.login = item.val);
+    const user = response.users ? response.users.find(user => user.login = item.val) : null;
     return { ...item, user };
   });
 }
@@ -67,7 +81,7 @@ export function getAssignees (query: {}): Promise<*> {
 export function getIssuesCount (query: {}): Promise<*> {
   const data = { ...query, ps: 1, facetMode: 'effort' };
   return searchIssues(data).then(r => {
-    return { issues: r.total, debt: r.debtTotal };
+    return { issues: r.paging.total, debt: r.debtTotal };
   });
 }
 
index d4a107033d1466546055f85e85a3e3522b3857e1..4208e8d409b87a6ff6bb4d31453120bb41c48ffd 100644 (file)
@@ -22,7 +22,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import Components from './Components';
 import Breadcrumbs from './Breadcrumbs';
-import SourceViewer from './../../../components/source-viewer/SourceViewer';
+import SourceViewer from './../../../components/SourceViewer/StandaloneSourceViewer';
 import Search from './Search';
 import ListFooter from '../../../components/controls/ListFooter';
 import { retrieveComponentChildren, retrieveComponent, loadMoreChildren, parseError } from '../utils';
@@ -203,7 +203,7 @@ class App extends React.Component {
 
             {shouldShowSourceViewer && (
                 <div className="spacer-top">
-                  <SourceViewer component={sourceViewer}/>
+                  <SourceViewer component={sourceViewer.key}/>
                 </div>
             )}
           </div>
index 33adce19b8a0416cbcc84bce0b848e3734fb53bb..20670ac9bcb2c12b8b1b6050a5bbccc17b3650b6 100644 (file)
@@ -25,7 +25,7 @@ import { translate } from '../../../helpers/l10n';
 const ComponentPin = ({ component }) => {
   const handleClick = e => {
     e.preventDefault();
-    Workspace.openComponent({ uuid: component.id });
+    Workspace.openComponent({ key: component.key });
   };
 
   return (
index d8ad4c31afc023f86085ad142e9ddf0db7228d17..4cd674b7c1f6f14a30c0aa2d832870697ad879f5 100644 (file)
@@ -118,7 +118,7 @@ export default class BubbleChart extends React.Component {
 
   handleBubbleClick (component) {
     if (['FIL', 'UTS'].includes(component.qualifier)) {
-      Workspace.openComponent({ uuid: component.id });
+      Workspace.openComponent({ key: component.key });
     } else {
       window.location = getComponentUrl(component.refKey || component.key);
     }
index 149582321dc83969bad9c4df263721dac0d3f54e..bbfc0ae32bf6d3dd09bf1d7c8fb03cb09c8b56e5 100644 (file)
  */
 import React from 'react';
 import classNames from 'classnames';
+import moment from 'moment';
 import ComponentsList from './ComponentsList';
 import ListHeader from './ListHeader';
 import Spinner from '../../components/Spinner';
-import SourceViewer from '../../../../components/source-viewer/SourceViewer';
+import SourceViewer from '../../../../components/SourceViewer/StandaloneSourceViewer';
 import ListFooter from '../../../../components/controls/ListFooter';
 
 export default class ListView extends React.Component {
@@ -104,6 +105,16 @@ export default class ListView extends React.Component {
     }
     const selectedIndex = components.indexOf(selected);
     const sourceViewerPeriod = metric.key.indexOf('new_') === 0 && !!leakPeriod ? leakPeriod : null;
+    const sourceViewerPeriodDate = sourceViewerPeriod != null ? moment(sourceViewerPeriod.date).toDate() : null;
+
+    const filterLine = sourceViewerPeriodDate != null ? line => {
+      if (line.scmDate) {
+        const scmDate = moment(line.scmDate).toDate();
+        return scmDate >= sourceViewerPeriodDate;
+      } else {
+        return false;
+      }
+    } : undefined;
 
     return (
         <div ref="container" className="measure-details-plain-list">
@@ -140,8 +151,8 @@ export default class ListView extends React.Component {
           {!!selected && (
               <div className="measure-details-viewer">
                 <SourceViewer
-                    component={selected}
-                    period={sourceViewerPeriod}/>
+                  component={selected.key}
+                  filterLine={filterLine}/>
               </div>
           )}
         </div>
index 554a290422798d5c5ba9981967cadcac86dff7c4..fb0bb744bd04b10f5ee3a3d3d064f46236f15309 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import React from 'react';
+import moment from 'moment';
 import ComponentsList from './ComponentsList';
 import ListHeader from './ListHeader';
 import Spinner from '../../components/Spinner';
-import SourceViewer from '../../../../components/source-viewer/SourceViewer';
+import SourceViewer from '../../../../components/SourceViewer/StandaloneSourceViewer';
 import ListFooter from '../../../../components/controls/ListFooter';
 
 export default class TreeView extends React.Component {
@@ -97,6 +98,16 @@ export default class TreeView extends React.Component {
 
     const selectedIndex = components.indexOf(selected);
     const sourceViewerPeriod = metric.key.indexOf('new_') === 0 && !!leakPeriod ? leakPeriod : null;
+    const sourceViewerPeriodDate = sourceViewerPeriod != null ? moment(sourceViewerPeriod.date).toDate() : null;
+
+    const filterLine = sourceViewerPeriodDate != null ? line => {
+      if (line.scmDate) {
+        const scmDate = moment(line.scmDate).toDate();
+        return scmDate >= sourceViewerPeriodDate;
+      } else {
+        return false;
+      }
+    } : undefined;
 
     return (
         <div ref="container" className="measure-details-plain-list">
@@ -133,8 +144,8 @@ export default class TreeView extends React.Component {
           {!!selected && (
               <div className="measure-details-viewer">
                 <SourceViewer
-                    component={selected}
-                    period={sourceViewerPeriod}/>
+                  component={selected.key}
+                  filterLine={filterLine}/>
               </div>
           )}
         </div>
index 2255ef5c4c824a8043475c39d1ff3d974aefd0d2..b599af3cd2bb80b6af93e2d5c184fbc7772cf3c4 100644 (file)
@@ -134,7 +134,7 @@ export default class MeasureTreemap extends React.Component {
     const isFile = node.qualifier === 'FIL' || node.qualifier === 'UTS';
 
     if (isFile) {
-      Workspace.openComponent({ uuid: node.id });
+      Workspace.openComponent({ key: node.key });
       return;
     }
 
index d889e4df77f27a005a6265c47e152d600d817397..041625d8243770b114f547635a2877ad1d431c6d 100644 (file)
  */
 // @flow
 import React from 'react';
-import SourceViewer from '../../../components/source-viewer/SourceViewer';
-import { getComponentNavigation } from '../../../api/nav';
+import SourceViewer from '../../../components/SourceViewer/StandaloneSourceViewer';
 
 export default class App extends React.Component {
-  static propTypes = {
-    location: React.PropTypes.object.isRequired
-  };
-
-  state = {};
-
-  componentDidMount () {
-    getComponentNavigation(this.props.location.query.id).then(component => (
-        this.setState({ component })
-    ));
+  props: {
+    location: {
+      query: {
+        id: string,
+        line?: string
+      }
+    }
   }
 
-  render () {
-    if (!this.state.component) {
-      return null;
+  scrollToLine = () => {
+    const { line } = this.props.location.query;
+    if (line) {
+      const row = document.querySelector(`.source-line[data-line-number="${line}"]`);
+      if (row) {
+        const rect = row.getBoundingClientRect();
+        const topOffset = window.innerHeight / 2 - 60;
+        const goal = rect.top - topOffset;
+        window.scrollTo(0, goal);
+      }
     }
+  };
 
-    const { line } = this.props.location.query;
+  render () {
+    const { id, line } = this.props.location.query;
+
+    const finalLine = line != null ? Number(line) : null;
 
     return (
         <div className="page">
-          <SourceViewer component={{ id: this.state.component.id }} line={line}/>
+        <SourceViewer
+          aroundLine={finalLine}
+          component={id}
+          highlightedLine={finalLine}
+          onLoaded={this.scrollToLine}/>
         </div>
     );
   }
diff --git a/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js b/server/sonar-web/src/main/js/apps/issues/component-viewer/issue-view.js
deleted file mode 100644 (file)
index 95e1819..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * 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 IssueView from '../workspace-list-item-view';
-
-export default IssueView.extend({
-  onRender () {
-    IssueView.prototype.onRender.apply(this, arguments);
-    this.$el.removeClass('issue-navigate-right issue-with-checkbox');
-  },
-
-  serializeData () {
-    return {
-      ...IssueView.prototype.serializeData.apply(this, arguments),
-      showComponent: false
-    };
-  }
-});
-
index 0f08c21112a26667bd48053c3ee8fca85512271d..49d7152c45fc15b9dd4cb7f0394e6205893a6e85 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import $ from 'jquery';
-import SourceViewer from '../../../components/source-viewer/main';
-import IssueView from './issue-view';
-
-export default SourceViewer.extend({
-  events () {
-    return {
-      ...SourceViewer.prototype.events.apply(this, arguments),
-      'click .js-close-component-viewer': 'closeComponentViewer',
-      'click .code-issue': 'selectIssue'
-    };
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import Marionette from 'backbone.marionette';
+import SourceViewer from '../../../components/SourceViewer/SourceViewer';
+import WithStore from '../../../components/shared/WithStore';
+
+export default Marionette.ItemView.extend({
+  template () {
+    return '<div></div>';
   },
 
   initialize (options) {
-    SourceViewer.prototype.initialize.apply(this, arguments);
-    return this.listenTo(options.app.state, 'change:selectedIndex', this.select);
+    this.handleLoadIssues = this.handleLoadIssues.bind(this);
+    this.scrollToBaseIssue = this.scrollToBaseIssue.bind(this);
+    this.selectIssue = this.selectIssue.bind(this);
+    this.listenTo(options.app.state, 'change:selectedIndex', this.select);
   },
 
-  onLoaded () {
-    SourceViewer.prototype.onLoaded.apply(this, arguments);
-    this.bindShortcuts();
-    if (this.baseIssue != null) {
-      this.baseIssue.trigger('locations', this.baseIssue);
-      this.scrollToLine(this.baseIssue.get('line'));
+  onRender () {
+    this.showViewer();
+  },
+
+  onDestroy () {
+    this.unbindShortcuts();
+    unmountComponentAtNode(this.el);
+  },
+
+  handleLoadIssues (component: string) {
+    // TODO fromLine: number, toLine: number
+    const issues = this.options.app.list.toJSON().filter(issue => issue.componentKey === component);
+    return Promise.resolve(issues);
+  },
+
+  showViewer (onLoaded) {
+    if (!this.baseIssue) {
+      return;
     }
+
+    const componentKey = this.baseIssue.get('component');
+
+    render((
+      <WithStore>
+        <SourceViewer
+          aroundLine={this.baseIssue.get('line')}
+          component={componentKey}
+          displayAllIssues={true}
+          loadIssues={this.handleLoadIssues}
+          onLoaded={onLoaded}
+          onIssueSelect={this.selectIssue}
+          selectedIssue={this.baseIssue.get('key')}/>
+      </WithStore>
+    ), this.el);
+  },
+
+  openFileByIssue (issue) {
+    this.baseIssue = issue;
+    this.selectedIssue = issue.get('key');
+    this.showViewer(this.scrollToBaseIssue);
+    this.bindShortcuts();
   },
 
   bindShortcuts () {
-    const that = this;
-    const doAction = function (action) {
-      const selectedIssueView = that.getSelectedIssueEl();
-      if (!selectedIssueView) {
-        return;
-      }
-      selectedIssueView.find('.js-issue-' + action).click();
-    };
     key('up', 'componentViewer', () => {
-      that.options.app.controller.selectPrev();
+      this.options.app.controller.selectPrev();
       return false;
     });
     key('down', 'componentViewer', () => {
-      that.options.app.controller.selectNext();
+      this.options.app.controller.selectNext();
       return false;
     });
     key('left,backspace', 'componentViewer', () => {
-      that.options.app.controller.closeComponentViewer();
+      this.options.app.controller.closeComponentViewer();
       return false;
     });
-    key('f', 'componentViewer', () => doAction('transition'));
-    key('a', 'componentViewer', () => doAction('assign'));
-    key('m', 'componentViewer', () => doAction('assign-to-me'));
-    key('p', 'componentViewer', () => doAction('plan'));
-    key('i', 'componentViewer', () => doAction('set-severity'));
-    key('c', 'componentViewer', () => doAction('comment'));
   },
 
   unbindShortcuts () {
-    return key.deleteScope('componentViewer');
-  },
-
-  onDestroy () {
-    SourceViewer.prototype.onDestroy.apply(this, arguments);
-    this.unbindScrollEvents();
-    return this.unbindShortcuts();
+    key.deleteScope('componentViewer');
   },
 
   select () {
     const selected = this.options.app.state.get('selectedIndex');
     const selectedIssue = this.options.app.list.at(selected);
-    if (selectedIssue.get('component') === this.model.get('key')) {
-      selectedIssue.trigger('locations', selectedIssue);
-      return this.scrollToIssue(selectedIssue.get('key'));
-    } else {
-      this.unbindShortcuts();
-      return this.options.app.controller.showComponentViewer(selectedIssue);
-    }
-  },
-
-  getSelectedIssueEl () {
-    const selected = this.options.app.state.get('selectedIndex');
-    if (selected == null) {
-      return null;
-    }
-    const selectedIssue = this.options.app.list.at(selected);
-    if (selectedIssue == null) {
-      return null;
-    }
-    const selectedIssueView = this.$('#issue-' + (selectedIssue.get('key')));
-    if (selectedIssueView.length > 0) {
-      return selectedIssueView;
-    } else {
-      return null;
-    }
-  },
-
-  selectIssue (e) {
-    const key = $(e.currentTarget).data('issue-key');
-    const issue = this.issues.find(model => model.get('key') === key);
-    const index = this.options.app.list.indexOf(issue);
-    return this.options.app.state.set({ selectedIndex: index });
-  },
-
-  scrollToIssue (key) {
-    const el = this.$('#issue-' + key);
-    if (el.length > 0) {
-      const line = el.closest('[data-line-number]').data('line-number');
-      return this.scrollToLine(line);
-    } else {
-      this.unbindShortcuts();
-      const selected = this.options.app.state.get('selectedIndex');
-      const selectedIssue = this.options.app.list.at(selected);
-      return this.options.app.controller.showComponentViewer(selectedIssue);
-    }
-  },
-
-  openFileByIssue (issue) {
-    this.baseIssue = issue;
-    const componentKey = issue.get('component');
-    const componentUuid = issue.get('componentUuid');
-    return this.open(componentUuid, componentKey);
-  },
-
-  linesLimit () {
-    let line = this.LINES_LIMIT / 2;
-    if ((this.baseIssue != null) && this.baseIssue.has('line')) {
-      line = Math.max(line, this.baseIssue.get('line'));
-    }
-    return {
-      from: line - this.LINES_LIMIT / 2 + 1,
-      to: line + this.LINES_LIMIT / 2
-    };
-  },
 
-  limitIssues (issues) {
-    const that = this;
-    let index = this.ISSUES_LIMIT / 2;
-    if ((this.baseIssue != null) && this.baseIssue.has('index')) {
-      index = Math.max(index, this.baseIssue.get('index'));
-    }
-    return issues.filter(issue => Math.abs(issue.get('index') - index) <= that.ISSUES_LIMIT / 2);
-  },
-
-  requestIssues () {
-    const that = this;
-    let r;
-    if (this.options.app.list.last().get('component') === this.model.get('key')) {
-      r = this.options.app.controller.fetchNextPage();
+    if (selectedIssue.get('component') === this.baseIssue.get('component')) {
+      this.baseIssue = selectedIssue;
+      this.showViewer(this.scrollToBaseIssue);
+      this.scrollToBaseIssue();
     } else {
-      r = $.Deferred().resolve().promise();
+      this.options.app.controller.showComponentViewer(selectedIssue);
     }
-    return r.done(() => {
-      that.issues.reset(that.options.app.list.filter(issue => issue.get('component') === that.model.key()));
-      that.issues.reset(that.limitIssues(that.issues));
-      return that.addIssuesPerLineMeta(that.issues);
-    });
-  },
-
-  renderIssues () {
-    this.issues.forEach(this.renderIssue, this);
-    return this.$('.source-line-issues').addClass('hidden');
-  },
-
-  renderIssue (issue) {
-    const issueView = new IssueView({
-      el: '#issue-' + issue.get('key'),
-      model: issue,
-      app: this.options.app
-    });
-    this.issueViews.push(issueView);
-    return issueView.render();
   },
 
   scrollToLine (line) {
     const row = this.$(`[data-line-number=${line}]`);
     const topOffset = $(window).height() / 2 - 60;
     const goal = row.length > 0 ? row.offset().top - topOffset : 0;
-    return $(window).scrollTop(goal);
+    $(window).scrollTop(goal);
+  },
+
+  selectIssue (issueKey) {
+    const issue = this.options.app.list.find(model => model.get('key') === issueKey);
+    const index = this.options.app.list.indexOf(issue);
+    this.options.app.state.set({ selectedIndex: index });
   },
 
-  closeComponentViewer () {
-    return this.options.app.controller.closeComponentViewer();
+  scrollToBaseIssue () {
+    this.scrollToLine(this.baseIssue.get('line'));
   }
 });
 
index edc868270507eeb10539d37fee14bfcd8dbbc3a1..71df7acdb7ce4798080c97c14b8353f01e83d61d 100644 (file)
@@ -44,14 +44,7 @@ export default Controller.extend({
       this.options.app.state.set({ selectedIndex: 0, page: 1 }, { silent: true });
       this.closeComponentViewer();
     }
-    const data = this._issuesParameters();
-    Object.assign(data, this.options.app.state.get('query'));
-    if (this.options.app.state.get('query').assigned_to_me) {
-      Object.assign(data, { assignees: '__me__' });
-    }
-    if (this.options.app.state.get('isContext')) {
-      Object.assign(data, this.options.app.state.get('contextQuery'));
-    }
+    const data = this.getQueryAsObject();
     return $.get(window.baseUrl + '/api/issues/search', data).done(r => {
       const issues = that.options.app.list.parseIssues(r);
       if (firstPage) {
diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-checkbox.hbs
deleted file mode 100644 (file)
index dbb50e2..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-<div class="js-toggle issue-checkbox-container">
-  <i class="issue-checkbox icon-checkbox {{#if selected}}icon-checkbox-checked{{/if}}"></i>
-</div>
diff --git a/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs b/server/sonar-web/src/main/js/apps/issues/templates/issues-issue-filter.hbs
deleted file mode 100644 (file)
index 16a212d..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-<li class="issue-meta">
-  <button class="button-link issue-action issue-action-with-options js-issue-filter"
-          aria-label="{{t "issue.filter_similar_issues"}}">
-    <i class="icon-filter icon-half-transparent"></i>&nbsp;<i class="icon-dropdown"></i>
-  </button>
-</li>
index 9c57d181aa93bf1f858fc70d7a3bce70b2444d6b..43817e46586a770353dead2d9ed850a4444c37ed 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import $ from 'jquery';
-import IssueView from '../../components/issue/issue-view';
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import Marionette from 'backbone.marionette';
+import Issue from '../../components/issue/Issue';
 import IssueFilterView from './issue-filter-view';
-import CheckboxTemplate from './templates/issues-issue-checkbox.hbs';
-import FilterTemplate from './templates/issues-issue-filter.hbs';
+import WithStore from '../../components/shared/WithStore';
 
 const SHOULD_NULL = {
   any: ['issues'],
@@ -31,35 +33,43 @@ const SHOULD_NULL = {
   assigned: ['assignees']
 };
 
-export default IssueView.extend({
-  checkboxTemplate: CheckboxTemplate,
-  filterTemplate: FilterTemplate,
+export default Marionette.ItemView.extend({
+  className: 'issues-workspace-list-item',
 
-  events () {
-    return {
-      ...IssueView.prototype.events.apply(this, arguments),
-      'click': 'selectCurrent',
-      'dblclick': 'openComponentViewer',
-      'click .js-issue-navigate': 'openComponentViewer',
-      'click .js-issue-filter': 'onIssueFilterClick',
-      'click .js-toggle': 'onIssueToggle'
-    };
+  initialize (options) {
+    this.openComponentViewer = this.openComponentViewer.bind(this);
+    this.onIssueFilterClick = this.onIssueFilterClick.bind(this);
+    this.onIssueCheck = this.onIssueCheck.bind(this);
+    this.listenTo(options.app.state, 'change:selectedIndex', this.showIssue);
+    this.listenTo(this.model, 'change:selected', this.showIssue);
   },
 
-  initialize (options) {
-    IssueView.prototype.initialize.apply(this, arguments);
-    this.listenTo(options.app.state, 'change:selectedIndex', this.select);
+  template () {
+    return '<div></div>';
   },
 
   onRender () {
-    IssueView.prototype.onRender.apply(this, arguments);
-    this.select();
-    this.addFilterSelect();
-    this.addCheckbox();
-    this.$el.addClass('issue-navigate-right');
-    if (this.options.app.state.get('canBulkChange')) {
-      this.$el.addClass('issue-with-checkbox');
-    }
+    this.showIssue();
+  },
+
+  onDestroy () {
+    unmountComponentAtNode(this.el);
+  },
+
+  showIssue () {
+    const selected = this.model.get('index') === this.options.app.state.get('selectedIndex');
+
+    render((
+      <WithStore>
+        <Issue
+          issue={this.model}
+          checked={this.model.get('selected')}
+          onCheck={this.onIssueCheck}
+          onClick={this.openComponentViewer}
+          onFilterClick={this.onIssueFilterClick}
+          selected={selected}/>
+      </WithStore>
+    ), this.el);
   },
 
   onIssueFilterClick (e) {
@@ -89,26 +99,21 @@ export default IssueView.extend({
     this.popup.render();
   },
 
-  onIssueToggle (e) {
+  onIssueCheck (e) {
     e.preventDefault();
+    e.stopPropagation();
     this.model.set({ selected: !this.model.get('selected') });
     const selected = this.model.collection.where({ selected: true }).length;
     this.options.app.state.set({ selected });
   },
 
-  addFilterSelect () {
-    this.$('.issue-table-meta-cell-first')
-        .find('.issue-meta-list')
-        .append(this.filterTemplate(this.model.toJSON()));
-  },
-
-  addCheckbox () {
-    this.$el.append(this.checkboxTemplate(this.model.toJSON()));
-  },
-
-  select () {
+  changeSelection () {
     const selected = this.model.get('index') === this.options.app.state.get('selectedIndex');
-    this.$el.toggleClass('selected', selected);
+    if (selected) {
+      this.select();
+    } else {
+      this.unselect();
+    }
   },
 
   selectCurrent () {
@@ -137,12 +142,5 @@ export default IssueView.extend({
     } else {
       return this.options.app.controller.showComponentViewer(this.model);
     }
-  },
-
-  serializeData () {
-    return {
-      ...IssueView.prototype.serializeData.apply(this, arguments),
-      showComponent: true
-    };
   }
 });
index 669f4c139e64b12b21ec99e1df5c586d36835a74..383d3145f24b0c4d4780d94757b02fc388b45698 100644 (file)
@@ -37,14 +37,6 @@ export default WorkspaceListView.extend({
 
   bindShortcuts () {
     const that = this;
-    const doAction = function (action) {
-      const selectedIssue = that.collection.at(that.options.app.state.get('selectedIndex'));
-      if (selectedIssue == null) {
-        return;
-      }
-      const selectedIssueView = that.children.findByModel(selectedIssue);
-      selectedIssueView.$('.js-issue-' + action).click();
-    };
     WorkspaceListView.prototype.bindShortcuts.apply(this, arguments);
     key('right', 'list', () => {
       const selectedIssue = that.collection.at(that.options.app.state.get('selectedIndex'));
@@ -56,26 +48,12 @@ export default WorkspaceListView.extend({
       selectedIssue.set({ selected: !selectedIssue.get('selected') });
       return false;
     });
-    key('f', 'list', () => doAction('transition'));
-    key('a', 'list', () => doAction('assign'));
-    key('m', 'list', () => doAction('assign-to-me'));
-    key('p', 'list', () => doAction('plan'));
-    key('i', 'list', () => doAction('set-severity'));
-    key('c', 'list', () => doAction('comment'));
-    key('t', 'list', () => doAction('edit-tags'));
   },
 
   unbindShortcuts () {
     WorkspaceListView.prototype.unbindShortcuts.apply(this, arguments);
     key.unbind('right', 'list');
     key.unbind('space', 'list');
-    key.unbind('f', 'list');
-    key.unbind('a', 'list');
-    key.unbind('m', 'list');
-    key.unbind('p', 'list');
-    key.unbind('i', 'list');
-    key.unbind('c', 'list');
-    key.unbind('t', 'list');
   },
 
   scrollTo () {
@@ -122,7 +100,6 @@ export default WorkspaceListView.extend({
 
   displayComponent (container, model) {
     const data = { ...model.toJSON() };
-    /* eslint-disable no-console */
     const qualifier = this.options.app.state.get('contextComponentQualifier');
     if (qualifier === 'VW' || qualifier === 'SVW') {
       Object.assign(data, { organization: undefined });
index 841475306470d66e7e8c8c66aa4d87b9bdcae0b7..91e636eb52ab014ce41b94dc0f0dcf15aaeb2fc1 100644 (file)
@@ -54,10 +54,10 @@ class App extends React.Component {
     const { component } = this.props;
 
     if (['FIL', 'UTS'].includes(component.qualifier)) {
-      const SourceViewer = require('../../../components/source-viewer/SourceViewer').default;
+      const SourceViewer = require('../../../components/SourceViewer/StandaloneSourceViewer').default;
       return (
           <div className="page">
-            <SourceViewer component={component}/>
+            <SourceViewer component={component.key}/>
           </div>
       );
     }
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.js
new file mode 100644 (file)
index 0000000..2c6e5b5
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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 { connect } from 'react-redux';
+import SourceViewerBase from './SourceViewerBase';
+import { receiveFavorites } from '../../store/favorites/duck';
+import { receiveIssues } from '../../store/issues/duck';
+
+const mapStateToProps = null;
+
+const onReceiveComponent = (component: { key: string, canMarkAsFavorite: boolean, fav: boolean }) => dispatch => {
+  if (component.canMarkAsFavorite) {
+    const favorites = [];
+    const notFavorites = [];
+    if (component.fav) {
+      favorites.push({ key: component.key });
+    } else {
+      notFavorites.push({ key: component.key });
+    }
+    dispatch(receiveFavorites(favorites, notFavorites));
+  }
+};
+
+const onReceiveIssues = (issues: Array<*>) => dispatch => {
+  dispatch(receiveIssues(issues));
+};
+
+const mapDispatchToProps = { onReceiveComponent, onReceiveIssues };
+
+export default connect(mapStateToProps, mapDispatchToProps)(SourceViewerBase);
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 (file)
index 0000000..2ad750e
--- /dev/null
@@ -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>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.js
new file mode 100644 (file)
index 0000000..32092dd
--- /dev/null
@@ -0,0 +1,222 @@
+/*
+ * 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 SourceViewerLine from './SourceViewerLine';
+import { translate } from '../../helpers/l10n';
+import type { Duplication, SourceLine } from './types';
+import type { Issue } from '../issue/types';
+
+const EMPTY_ARRAY = [];
+
+const ZERO_LINE = {
+  code: '',
+  duplicated: false,
+  line: 0
+};
+
+export default class SourceViewerCode extends React.Component {
+  props: {
+    displayAllIssues: boolean,
+    duplications?: Array<Duplication>,
+    duplicationsByLine: { [number]: Array<number> },
+    duplicatedFiles?: Array<{ key: string }>,
+    filterLine?: (SourceLine) => boolean,
+    hasSourcesAfter: boolean,
+    hasSourcesBefore: 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 }>
+      }
+    },
+    loadDuplications: (SourceLine, HTMLElement) => void,
+    loadSourcesAfter: () => void,
+    loadSourcesBefore: () => void,
+    loadingSourcesAfter: boolean,
+    loadingSourcesBefore: boolean,
+    onCoverageClick: (SourceLine, HTMLElement) => void,
+    onDuplicationClick: (number, number) => void,
+    onIssueSelect: (string) => void,
+    onIssueUnselect: () => void,
+    onLineClick: (number, HTMLElement) => void,
+    onSCMClick: (SourceLine, HTMLElement) => void,
+    onSymbolClick: (string) => void,
+    selectedIssue: string | null,
+    sources: Array<SourceLine>,
+    symbolsByLine: { [number]: Array<string> }
+  };
+
+  isSCMChanged (s: SourceLine, p: null | SourceLine) {
+    let changed = true;
+    if (p != null && s.scmAuthor != null && p.scmAuthor != null) {
+      changed = (s.scmAuthor !== p.scmAuthor) || (s.scmDate !== p.scmDate);
+    }
+    return changed;
+  }
+
+  getDuplicationsForLine (line: SourceLine) {
+    return this.props.duplicationsByLine[line.line] || EMPTY_ARRAY;
+  }
+
+  getIssuesForLine (line: SourceLine): Array<string> {
+    return this.props.issuesByLine[line.line] || EMPTY_ARRAY;
+  }
+
+  getIssueLocationsForLine (line: SourceLine) {
+    return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY;
+  }
+
+  getSecondaryIssueLocationsForLine (line: SourceLine, issueKey: string) {
+    const index = this.props.issueSecondaryLocationsByIssueByLine;
+    if (index[issueKey] == null) {
+      return EMPTY_ARRAY;
+    }
+    return index[issueKey][line.line] || EMPTY_ARRAY;
+  }
+
+  getSecondaryIssueLocationMessagesForLine (line: SourceLine, issueKey: string) {
+    return this.props.issueSecondaryLocationMessagesByIssueByLine[issueKey][line.line] || EMPTY_ARRAY;
+  }
+
+  renderLine = (
+    line: SourceLine,
+    index: number,
+    displayCoverage: boolean,
+    displayDuplications: boolean,
+    displayFiltered: boolean,
+    displayIssues: boolean
+  ) => {
+    const { filterLine, selectedIssue, sources } = this.props;
+    const filtered = filterLine ? filterLine(line) : null;
+    const secondaryIssueLocations = selectedIssue ?
+      this.getSecondaryIssueLocationsForLine(line, selectedIssue) : EMPTY_ARRAY;
+    const secondaryIssueLocationMessages = selectedIssue ?
+      this.getSecondaryIssueLocationMessagesForLine(line, selectedIssue) : EMPTY_ARRAY;
+
+    const duplicationsCount = this.props.duplications ? this.props.duplications.length : 0;
+
+    const issuesForLine = this.getIssuesForLine(line);
+
+    // for the following properties pass null if the line for sure is not impacted
+    const symbolsForLine = this.props.symbolsByLine[line.line] || [];
+    const { highlightedSymbol } = this.props;
+    const optimizedHighlightedSymbol = highlightedSymbol != null && symbolsForLine.includes(highlightedSymbol) ?
+      highlightedSymbol : null;
+
+    const optimizedSelectedIssue = selectedIssue != null && issuesForLine.includes(selectedIssue) ?
+      selectedIssue : null;
+
+    return (
+      <SourceViewerLine
+        displayAllIssues={this.props.displayAllIssues}
+        displayCoverage={displayCoverage}
+        displayDuplications={displayDuplications}
+        displayFiltered={displayFiltered}
+        displayIssues={displayIssues}
+        displaySCM={this.isSCMChanged(line, index > 0 ? sources[index - 1] : null)}
+        duplications={this.getDuplicationsForLine(line)}
+        duplicationsCount={duplicationsCount}
+        filtered={filtered}
+        highlighted={line.line === this.props.highlightedLine}
+        highlightedSymbol={optimizedHighlightedSymbol}
+        issueLocations={this.getIssueLocationsForLine(line)}
+        issues={issuesForLine}
+        key={line.line}
+        line={line}
+        loadDuplications={this.props.loadDuplications}
+        onClick={this.props.onLineClick}
+        onCoverageClick={this.props.onCoverageClick}
+        onDuplicationClick={this.props.onDuplicationClick}
+        onIssueSelect={this.props.onIssueSelect}
+        onIssueUnselect={this.props.onIssueUnselect}
+        onSCMClick={this.props.onSCMClick}
+        onSymbolClick={this.props.onSymbolClick}
+        secondaryIssueLocations={secondaryIssueLocations}
+        secondaryIssueLocationMessages={secondaryIssueLocationMessages}
+        selectedIssue={optimizedSelectedIssue}/>
+    );
+  };
+
+  render () {
+    const { sources } = this.props;
+
+    const hasCoverage = sources.some(s => s.coverageStatus != null);
+    const hasDuplications = sources.some(s => s.duplicated);
+    const displayFiltered = this.props.filterLine != null;
+    const hasIssues = this.props.issues.length > 0;
+
+    const hasFileIssues = hasIssues && this.props.issues.some(issue => !issue.line);
+
+    return (
+      <div>
+        {this.props.hasSourcesBefore && (
+          <div className="source-viewer-more-code">
+            {this.props.loadingSourcesBefore ? (
+                <div className="js-component-viewer-loading-before">
+                  <i className="spinner"/>
+                  <span className="note spacer-left">{translate('source_viewer.loading_more_code')}</span>
+                </div>
+              ) : (
+                <button className="js-component-viewer-source-before" onClick={this.props.loadSourcesBefore}>
+                  {translate('source_viewer.load_more_code')}
+                </button>
+              )}
+          </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>
+
+        {this.props.hasSourcesAfter && (
+          <div className="source-viewer-more-code">
+            {this.props.loadingSourcesAfter ? (
+                <div className="js-component-viewer-loading-after">
+                  <i className="spinner"/>
+                  <span className="note spacer-left">{translate('source_viewer.loading_more_code')}</span>
+                </div>
+              ) : (
+                <button className="js-component-viewer-source-after" onClick={this.props.loadSourcesAfter}>
+                  {translate('source_viewer.load_more_code')}
+                </button>
+              )}
+          </div>
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.js
new file mode 100644 (file)
index 0000000..14dedd8
--- /dev/null
@@ -0,0 +1,185 @@
+/*
+ * 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 { Link } from 'react-router';
+import QualifierIcon from '../shared/qualifier-icon';
+import FavoriteContainer from '../controls/FavoriteContainer';
+import Workspace from '../workspace/main';
+import { getProjectUrl, getIssuesUrl } from '../../helpers/urls';
+import { collapsedDirFromPath, fileFromPath } from '../../helpers/path';
+import { translate } from '../../helpers/l10n';
+import { formatMeasure } from '../../helpers/measures';
+
+export default class SourceViewerHeader extends React.Component {
+  props: {
+    component: {
+      canMarkAsFavorite: boolean,
+      key: string,
+      measures: {
+        coverage?: string,
+        duplicationDensity?: string,
+        issues?: string,
+        lines?: string,
+        tests?: string
+      },
+      path: string,
+      project: string,
+      projectName: string,
+      q: string,
+      subProject?: string,
+      subProjectName?: string
+    },
+    openNewWindow: () => void,
+    showMeasures: () => void
+  };
+
+  showMeasures = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    this.props.showMeasures();
+  };
+
+  openNewWindow = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    this.props.openNewWindow();
+  };
+
+  openInWorkspace = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    const { key } = this.props.component;
+    Workspace.openComponent({ key });
+  };
+
+  render () {
+    const { key, measures, path, project, projectName, q, subProject, subProjectName } = this.props.component;
+    const isUnitTest = q === 'UTS';
+    // TODO check if source viewer is displayed inside workspace
+    const workspace = false;
+    const rawSourcesLink = `${window.baseUrl}/api/sources/raw?key=${encodeURIComponent(this.props.component.key)}`;
+
+    // TODO favorite
+    return (
+      <div className="source-viewer-header">
+        <div className="source-viewer-header-component">
+          <div className="component-name">
+            <div className="component-name-parent">
+              <Link to={getProjectUrl(project)} className="link-with-icon">
+                <QualifierIcon qualifier="TRK"/> <span>{projectName}</span>
+              </Link>
+            </div>
+
+            {subProject != null && (
+              <div className="component-name-parent">
+                <Link to={getProjectUrl(subProject)} className="link-with-icon">
+                  <QualifierIcon qualifier="BRC"/> <span>{subProjectName}</span>
+                </Link>
+              </div>
+            )}
+
+            <div className="component-name-path">
+              <QualifierIcon qualifier={q}/>
+              {' '}
+              <span>{collapsedDirFromPath(path)}</span>
+              <span className="component-name-file">{fileFromPath(path)}</span>
+
+              {this.props.component.canMarkAsFavorite && (
+                <FavoriteContainer className="component-name-favorite" componentKey={key}/>
+              )}
+            </div>
+          </div>
+        </div>
+
+        <div className="dropdown source-viewer-header-actions">
+          <a className="js-actions icon-list dropdown-toggle"
+             data-toggle="dropdown"
+             title={translate('component_viewer.more_actions')}/>
+          <ul className="dropdown-menu dropdown-menu-right">
+            <li>
+              <a className="js-measures" href="#" onClick={this.showMeasures}>
+                {translate('component_viewer.show_details')}
+              </a>
+            </li>
+            <li>
+              <a className="js-new-window" href="#" onClick={this.openNewWindow}>
+                {translate('component_viewer.new_window')}
+              </a>
+            </li>
+            {!workspace && (
+              <li>
+                <a className="js-workspace" href="#" onClick={this.openInWorkspace}>
+                  {translate('component_viewer.open_in_workspace')}
+                </a>
+              </li>
+            )}
+            <li>
+              <a className="js-raw-source" href={rawSourcesLink} target="_blank">
+                {translate('component_viewer.show_raw_source')}
+              </a>
+            </li>
+          </ul>
+         </div>
+
+        <div className="source-viewer-header-measures">
+          {isUnitTest && (
+            <div className="source-viewer-header-measure">
+              <span className="source-viewer-header-measure-value">{formatMeasure(measures.tests, 'SHORT_INT')}</span>
+              <span className="source-viewer-header-measure-label">{translate('metric.tests.name')}</span>
+            </div>
+          )}
+
+          {!isUnitTest && (
+            <div className="source-viewer-header-measure">
+              <span className="source-viewer-header-measure-value">{formatMeasure(measures.lines, 'SHORT_INT')}</span>
+              <span className="source-viewer-header-measure-label">{translate('metric.lines.name')}</span>
+            </div>
+          )}
+
+          <div className="source-viewer-header-measure">
+            <span className="source-viewer-header-measure-value">
+              <Link to={getIssuesUrl({ resolved: 'false', componentKeys: key })}
+                    className="source-viewer-header-external-link" target="_blank">
+                {measures.issues != null ? formatMeasure(measures.issues, 'SHORT_INT') : 0}
+                {' '}
+                <i className="icon-detach"/>
+              </Link>
+            </span>
+            <span className="source-viewer-header-measure-label">{translate('metric.violations.name')}</span>
+          </div>
+
+          {measures.coverage != null && (
+            <div className="source-viewer-header-measure">
+              <span className="source-viewer-header-measure-value">{formatMeasure(measures.coverage, 'PERCENT')}</span>
+              <span className="source-viewer-header-measure-label">{translate('metric.coverage.name')}</span>
+            </div>
+          )}
+
+          {measures.duplicationDensity != null && (
+            <div className="source-viewer-header-measure">
+            <span className="source-viewer-header-measure-value">
+              {formatMeasure(measures.duplicationDensity, 'PERCENT')}
+            </span>
+              <span className="source-viewer-header-measure-label">{translate('duplications')}</span>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerIssuesIndicator.js
new file mode 100644 (file)
index 0000000..f699394
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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 { connect } from 'react-redux';
+import SeverityIcon from '../shared/severity-icon';
+import { getIssueByKey } from '../../store/rootReducer';
+import { sortBySeverity } from '../../helpers/issues';
+
+class SourceViewerIssuesIndicator extends React.Component {
+  props: {
+    issue: { severity: string }
+  };
+
+  render () {
+    return (
+      <SeverityIcon severity={this.props.issue.severity}/>
+    );
+  }
+}
+
+const mapStateToProps = (state, ownProps: { issues: Array<string> }) => {
+  const issues = ownProps.issues.map(issueKey => getIssueByKey(state, issueKey));
+  return { issue: sortBySeverity(issues)[0] };
+};
+
+export default connect(mapStateToProps)(SourceViewerIssuesIndicator);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerLine.js
new file mode 100644 (file)
index 0000000..72cb0d5
--- /dev/null
@@ -0,0 +1,377 @@
+/*
+ * 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 times from 'lodash/times';
+import ConnectedIssue from '../issue/ConnectedIssue';
+import SourceViewerIssuesIndicator from './SourceViewerIssuesIndicator';
+import { translate } from '../../helpers/l10n';
+import { splitByTokens, highlightSymbol, highlightIssueLocations, generateHTML } from './helpers/highlight';
+import type { SourceLine } from './types';
+
+type Props = {
+  displayAllIssues: boolean,
+  displayCoverage: boolean,
+  displayDuplications: boolean,
+  displayFiltered: boolean,
+  displayIssues: boolean,
+  displaySCM: boolean,
+  duplications: Array<number>,
+  duplicationsCount: number,
+  filtered: boolean | null,
+  highlighted: boolean,
+  highlightedSymbol: string | null,
+  issueLocations: Array<{ from: number, to: number }>,
+  issues: Array<string>,
+  line: SourceLine,
+  loadDuplications: (SourceLine, HTMLElement) => void,
+  onClick: (number, HTMLElement) => void,
+  onCoverageClick: (SourceLine, HTMLElement) => void,
+  onDuplicationClick: (number, number) => void,
+  onIssueSelect: (string) => void,
+  onIssueUnselect: () => void,
+  onSCMClick: (SourceLine, HTMLElement) => void,
+  onSymbolClick: (string) => void,
+  selectedIssue: string | null,
+  // $FlowFixMe
+  secondaryIssueLocations: Array<{ from: number, to: number }>,
+  // $FlowFixMe
+  secondaryIssueLocationMessages: Array<{ msg: string, index?: number }>
+};
+
+type State = {
+  issuesOpen: boolean
+};
+
+export default class SourceViewerLine extends React.PureComponent {
+  codeNode: HTMLElement;
+  props: Props;
+  issueElements: { [string]: HTMLElement } = {};
+  issueViews: { [string]: { destroy: () => void } } = {};
+  state: State = { issuesOpen: false };
+  symbols: NodeList<HTMLElement>;
+
+  componentDidMount () {
+    this.attachEvents();
+  }
+
+  componentWillUpdate () {
+    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('');
+
+    this.attachEvents();
+  }
+
+  componentWillUnmount () {
+    this.detachEvents();
+  }
+
+  attachEvents () {
+    this.symbols = this.codeNode.querySelectorAll('.sym');
+    for (const symbol of this.symbols) {
+      symbol.addEventListener('click', this.handleSymbolClick);
+    }
+  }
+
+  detachEvents () {
+    if (this.symbols) {
+      for (const symbol of this.symbols) {
+        symbol.removeEventListener('click', this.handleSymbolClick);
+      }
+    }
+  }
+
+  handleClick = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    this.props.onClick(this.props.line.line, e.target);
+  };
+
+  handleCoverageClick = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    this.props.onCoverageClick(this.props.line, e.target);
+  };
+
+  handleIssuesIndicatorClick = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    this.setState(prevState => {
+      // TODO not sure if side effects allowed here
+      if (!prevState.issuesOpen) {
+        const { issues } = this.props;
+        if (issues.length > 0) {
+          this.props.onIssueSelect(issues[0]);
+        }
+      } else {
+        this.props.onIssueUnselect();
+      }
+
+      return { issuesOpen: !prevState.issuesOpen };
+    });
+  }
+
+  handleSCMClick = (e: SyntheticInputEvent) => {
+    e.preventDefault();
+    this.props.onSCMClick(this.props.line, e.target);
+  }
+
+  handleSymbolClick = (e: Object) => {
+    e.preventDefault();
+    const key = e.currentTarget.className.match(/sym-\d+/);
+    if (key && key[0]) {
+      this.props.onSymbolClick(key[0]);
+    }
+  };
+
+  handleIssueSelect = (issueKey: string) => {
+    this.props.onIssueSelect(issueKey);
+  };
+
+  renderLineNumber () {
+    const { line } = this.props;
+    return (
+      <td className="source-meta source-line-number"
+          // don't display 0
+          data-line-number={line.line ? line.line : undefined}
+          role={line.line ? 'button' : undefined}
+          tabIndex={line.line ? 0 : undefined}
+          onClick={line.line ? this.handleClick : undefined}/>
+    );
+  }
+
+  renderSCM () {
+    const { line } = this.props;
+    const clickable = !!line.line;
+    return (
+      <td className="source-meta source-line-scm"
+          data-line-number={line.line}
+          role={clickable ? 'button' : undefined}
+          tabIndex={clickable ? 0 : undefined}
+          onClick={clickable ? this.handleSCMClick : undefined}>
+        {this.props.displaySCM && (
+          <div className="source-line-scm-inner" data-author={line.scmAuthor}/>
+        )}
+      </td>
+    );
+  }
+
+  renderCoverage () {
+    const { line } = this.props;
+    const className = 'source-meta source-line-coverage' +
+      (line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : '');
+    return (
+      <td className={className}
+          data-line-number={line.line}
+          title={line.coverageStatus != null && translate('source_viewer.tooltip', line.coverageStatus)}
+          data-placement={line.coverageStatus != null && 'right'}
+          data-toggle={line.coverageStatus != null && 'tooltip'}
+          role={line.coverageStatus != null ? 'button' : undefined}
+          tabIndex={line.coverageStatus != null ? 0 : undefined}
+          onClick={line.coverageStatus != null && this.handleCoverageClick}>
+        <div className="source-line-bar"/>
+      </td>
+    );
+  }
+
+  renderDuplications () {
+    const { line } = this.props;
+    const className = classNames('source-meta', 'source-line-duplications', {
+      'source-line-duplicated': line.duplicated
+    });
+
+    const handleDuplicationClick = (e: SyntheticInputEvent) => {
+      e.preventDefault();
+      this.props.loadDuplications(this.props.line, e.target);
+    };
+
+    return (
+      <td className={className}
+          title={line.duplicated && translate('source_viewer.tooltip.duplicated_line')}
+          data-placement={line.duplicated && 'right'}
+          data-toggle={line.duplicated && 'tooltip'}
+          role="button"
+          tabIndex="0"
+          onClick={handleDuplicationClick}>
+        <div className="source-line-bar"/>
+      </td>
+    );
+  }
+
+  renderDuplicationsExtra () {
+    const { duplications, duplicationsCount } = this.props;
+    return times(duplicationsCount).map(index => this.renderDuplication(index, duplications.includes(index)));
+  }
+
+  renderDuplication = (index: number, duplicated: boolean) => {
+    const className = classNames('source-meta', 'source-line-duplications-extra', {
+      'source-line-duplicated': duplicated
+    });
+
+    const handleDuplicationClick = (e: SyntheticInputEvent) => {
+      e.preventDefault();
+      this.props.onDuplicationClick(index, this.props.line.line);
+    };
+
+    return (
+      <td key={index}
+          className={className}
+          data-line-number={this.props.line.line}
+          data-index={index}
+          title={duplicated ? translate('source_viewer.tooltip.duplicated_block') : undefined}
+          data-placement={duplicated ? 'right' : undefined}
+          data-toggle={duplicated ? 'tooltip' : undefined}
+          role={duplicated ? 'button' : undefined}
+          tabIndex={duplicated ? '0' : undefined}
+          onClick={duplicated ? handleDuplicationClick : undefined}>
+        <div className="source-line-bar"/>
+      </td>
+    );
+  };
+
+  renderIssuesIndicator () {
+    const { issues } = this.props;
+    const hasIssues = issues.length > 0;
+    const className = classNames('source-meta', 'source-line-issues', { 'source-line-with-issues': hasIssues });
+    const onClick = hasIssues ? this.handleIssuesIndicatorClick : undefined;
+
+    return (
+      <td className={className}
+          data-line-number={this.props.line.line}
+          role="button"
+          tabIndex="0"
+          onClick={onClick}>
+        {hasIssues && (
+          <SourceViewerIssuesIndicator issues={issues}/>
+        )}
+        {issues.length > 1 && (
+          <span className="source-line-issues-counter">{issues.length}</span>
+        )}
+      </td>
+    );
+  }
+
+  renderSecondaryIssueLocationMessages (locationMessages: Array<{ msg: string, index?: number }>) {
+    const limitString = (str: string) => (
+      str.length > 30 ? str.substr(0, 30) + '...' : str
+    );
+
+    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>
+        ))}
+      </div>
+    );
+  }
+
+  renderCode () {
+    const { line, highlightedSymbol, issueLocations, issues, secondaryIssueLocations } = this.props;
+    const { secondaryIssueLocationMessages } = this.props;
+    const className = classNames('source-line-code', 'code', { 'has-issues': issues.length > 0 });
+
+    const code = line.code || '';
+    let tokens = splitByTokens(code);
+
+    if (highlightedSymbol) {
+      tokens = highlightSymbol(tokens, highlightedSymbol);
+    }
+
+    if (issueLocations.length > 0) {
+      tokens = highlightIssueLocations(tokens, issueLocations);
+    }
+
+    if (secondaryIssueLocations) {
+      tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'source-line-code-secondary-issue');
+    }
+
+    const finalCode = generateHTML(tokens);
+
+    const showIssues = (this.state.issuesOpen || this.props.displayAllIssues) && issues.length > 0;
+
+    return (
+      <td className={className} data-line-number={line.line}>
+        <div className="source-line-code-inner">
+          <pre ref={node => this.codeNode = node} dangerouslySetInnerHTML={{ __html: finalCode }}/>
+          {secondaryIssueLocationMessages != null && secondaryIssueLocationMessages.length > 0 && (
+            this.renderSecondaryIssueLocationMessages(secondaryIssueLocationMessages)
+          )}
+        </div>
+        {showIssues && (
+          <div className="issue-list">
+            {issues.map(issue => (
+              <ConnectedIssue
+                key={issue}
+                issueKey={issue}
+                onClick={this.handleIssueSelect}
+                selected={this.props.selectedIssue === issue}/>
+            ))}
+          </div>
+        )}
+      </td>
+    );
+  }
+
+  render () {
+    const { line, duplicationsCount, filtered } = this.props;
+    const className = classNames('source-line', {
+      'source-line-highlighted': this.props.highlighted,
+      'source-line-shadowed': filtered === false,
+      'source-line-filtered': filtered === true
+    });
+
+    return (
+      <tr className={className} data-line-number={line.line}>
+        {this.renderLineNumber()}
+
+        {this.renderSCM()}
+
+        {this.props.displayCoverage && this.renderCoverage()}
+
+        {this.props.displayDuplications && this.renderDuplications()}
+
+        {duplicationsCount > 0 && this.renderDuplicationsExtra()}
+
+        {this.props.displayIssues && !this.props.displayAllIssues && this.renderIssuesIndicator()}
+
+        {this.props.displayFiltered && (
+          <td className="source-meta source-line-filtered-container" data-line-number={line.line}>
+            <div className="source-line-bar"/>
+          </td>
+        )}
+
+        {this.renderCode()}
+      </tr>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js b/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewer.js
new file mode 100644 (file)
index 0000000..d673bd4
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * 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 { connect } from 'react-redux';
+import StandaloneSourceViewerBase from './StandaloneSourceViewerBase';
+import { receiveFavorites } from '../../store/favorites/duck';
+import { receiveIssues } from '../../store/issues/duck';
+
+const mapStateToProps = null;
+
+const onReceiveComponent = (component: { key: string, canMarkAsFavorite: boolean, fav: boolean }) => dispatch => {
+  if (component.canMarkAsFavorite) {
+    const favorites = [];
+    const notFavorites = [];
+    if (component.fav) {
+      favorites.push({ key: component.key });
+    } else {
+      notFavorites.push({ key: component.key });
+    }
+    dispatch(receiveFavorites(favorites, notFavorites));
+  }
+};
+
+const onReceiveIssues = (issues: Array<*>) => dispatch => {
+  dispatch(receiveIssues(issues));
+};
+
+const mapDispatchToProps = { onReceiveComponent, onReceiveIssues };
+
+export default connect(mapStateToProps, mapDispatchToProps)(StandaloneSourceViewerBase);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js b/server/sonar-web/src/main/js/components/SourceViewer/StandaloneSourceViewerBase.js
new file mode 100644 (file)
index 0000000..ea28e00
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * 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 SourceViewerBase from './SourceViewerBase';
+
+type State = {
+  selectedIssue: string | null
+};
+
+export default class StandaloneSourceViewerBase extends React.Component {
+  state: State = {
+    selectedIssue: null
+  };
+
+  handleIssueSelect = (issue: string) => {
+    this.setState({ selectedIssue: issue });
+  };
+
+  handleIssueUnselect = () => {
+    this.setState({ selectedIssue: null });
+  };
+
+  render () {
+    return (
+      <SourceViewerBase
+        {...this.props}
+        onIssueSelect={this.handleIssueSelect}
+        onIssueUnselect={this.handleIssueUnselect}
+        selectedIssue={this.state.selectedIssue}/>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.js
new file mode 100644 (file)
index 0000000..2f99ed8
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * 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 type { SourceLine } from '../types';
+
+const getCoverageStatus = (s: SourceLine): string | null => {
+  let status = null;
+  if (s.lineHits != null && s.lineHits > 0) {
+    status = 'partially-covered';
+  }
+  if (s.lineHits != null && s.lineHits > 0 && s.conditions === s.coveredConditions) {
+    status = 'covered';
+  }
+  if (s.lineHits === 0 || s.coveredConditions === 0) {
+    status = 'uncovered';
+  }
+  return status;
+};
+
+export default getCoverageStatus;
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
new file mode 100644 (file)
index 0000000..0adc3f0
--- /dev/null
@@ -0,0 +1,115 @@
+/*
+ * 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 escapeHtml from 'escape-html';
+
+type Token = { className: string, text: string };
+type Tokens = Array<Token>;
+
+const ISSUE_LOCATION_CLASS = 'source-line-code-issue';
+
+export const splitByTokens = (code: string, rootClassName: string = ''): Tokens => {
+  const container = document.createElement('div');
+  let tokens = [];
+  container.innerHTML = code;
+  [].forEach.call(container.childNodes, node => {
+    if (node.nodeType === 1) {
+      // ELEMENT NODE
+      const fullClassName = rootClassName ? (rootClassName + ' ' + node.className) : node.className;
+      const innerTokens = splitByTokens(node.innerHTML, fullClassName);
+      tokens = tokens.concat(innerTokens);
+    }
+    if (node.nodeType === 3) {
+      // TEXT NODE
+      tokens.push({ className: rootClassName, text: node.nodeValue });
+    }
+  });
+  return tokens;
+};
+
+export const highlightSymbol = (tokens: Tokens, symbol: string): Tokens => (
+  tokens.map(token => token.className.includes(symbol) ?
+    { ...token, className: `${token.className} highlighted` } :
+    token
+));
+
+/**
+ * Intersect two ranges
+ * @param s1 Start position of the first range
+ * @param e1 End position of the first range
+ * @param s2 Start position of the second range
+ * @param e2 End position of the second range
+ */
+const intersect = (s1: number, e1: number, s2: number, e2: number): { from: number, to: number } => {
+  return { from: Math.max(s1, s2), to: Math.min(e1, e2) };
+};
+
+/**
+ * Get the substring of a string
+ * @param str A string
+ * @param from "From" offset
+ * @param to "To" offset
+ * @param acc Global offset to eliminate
+ */
+const part = (str: string, from: number, to: number, acc: number): string => {
+  // we do not want negative number as the first argument of `substr`
+  return from >= acc ? str.substr(from - acc, to - from) : str.substr(0, to - from);
+};
+
+/**
+ * Highlight issue locations in the list of tokens
+ */
+export const highlightIssueLocations = (
+  tokens: Tokens,
+  issueLocations: Array<{ from: number, to: number }>,
+  rootClassName: string = ISSUE_LOCATION_CLASS
+): Tokens => {
+  issueLocations.forEach(location => {
+    const nextTokens = [];
+    let acc = 0;
+    tokens.forEach(token => {
+      const x = intersect(acc, acc + token.text.length, location.from, location.to);
+      const p1 = part(token.text, acc, x.from, acc);
+      const p2 = part(token.text, x.from, x.to, acc);
+      const p3 = part(token.text, x.to, acc + token.text.length, acc);
+      if (p1.length) {
+        nextTokens.push({ className: token.className, text: p1 });
+      }
+      if (p2.length) {
+        const newClassName = token.className.indexOf(rootClassName) === -1 ?
+            `${token.className} ${rootClassName}` :
+            token.className;
+        nextTokens.push({ className: newClassName, text: p2 });
+      }
+      if (p3.length) {
+        nextTokens.push({ className: token.className, text: p3 });
+      }
+      acc += token.text.length;
+    });
+    tokens = nextTokens.slice();
+  });
+  return tokens;
+};
+
+export const generateHTML = (tokens: Tokens): string => {
+  return tokens.map(token => (
+      `<span class="${token.className}">${escapeHtml(token.text)}</span>`
+  )).join('');
+};
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
new file mode 100644 (file)
index 0000000..a9016ef
--- /dev/null
@@ -0,0 +1,119 @@
+/*
+ * 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 { splitByTokens } from './highlight';
+import { getLinearLocations, getIssueLocations } from './issueLocations';
+import type { Issue } from '../../issue/types';
+import type { SourceLine } from '../types';
+
+export const issuesByLine = (issues: Array<Issue>) => {
+  const index = {};
+  issues.forEach(issue => {
+    const line = issue.line || 0;
+    if (!(line in index)) {
+      index[line] = [];
+    }
+    index[line].push(issue.key);
+  });
+  return index;
+};
+
+export const locationsByLine = (issues: Array<Issue>) => {
+  const index = {};
+  issues.forEach(issue => {
+    getLinearLocations(issue.textRange).forEach(location => {
+      if (!(location.line in index)) {
+        index[location.line] = [];
+      }
+      index[location.line].push(location);
+    });
+  });
+  return index;
+};
+
+export const locationsByIssueAndLine = (issues: Array<Issue>) => {
+  const index = {};
+  issues.forEach(issue => {
+    const byLine = {};
+    getIssueLocations(issue).forEach(location => {
+      getLinearLocations(location.textRange).forEach(linearLocation => {
+        if (!(linearLocation.line in byLine)) {
+          byLine[linearLocation.line] = [];
+        }
+        byLine[linearLocation.line].push({ from: linearLocation.from, to: linearLocation.to });
+      });
+    });
+    index[issue.key] = byLine;
+  });
+  return index;
+};
+
+export const locationMessagesByIssueAndLine = (issues: Array<Issue>) => {
+  const index = {};
+  issues.forEach(issue => {
+    const byLine = {};
+    getIssueLocations(issue).forEach(location => {
+      const line = location.textRange ? location.textRange.startLine : 0;
+      if (!(line in byLine)) {
+        byLine[line] = [];
+      }
+      byLine[line].push({ msg: location.msg, index: location.index });
+    });
+    index[issue.key] = byLine;
+  });
+  return index;
+};
+
+export const duplicationsByLine = (duplications: Array<*> | null) => {
+  if (duplications == null) {
+    return {};
+  }
+
+  const duplicationsByLine = {};
+
+  duplications.forEach(({ blocks }, duplicationIndex) => {
+    blocks.forEach(block => {
+      if (block._ref === '1') {
+        for (let line = block.from; line < block.from + block.size; line++) {
+          if (!(line in duplicationsByLine)) {
+            duplicationsByLine[line] = [];
+          }
+          duplicationsByLine[line].push(duplicationIndex);
+        }
+      }
+    });
+  });
+
+  return duplicationsByLine;
+};
+
+export const symbolsByLine = (sources: Array<SourceLine>) => {
+  const index = {};
+  sources.forEach(line => {
+    const tokens = splitByTokens(line.code);
+    index[line.line] = tokens
+      .map(token => {
+        const key = token.className.match(/sym-\d+/);
+        return key && key[0];
+      })
+      .filter(key => key);
+  });
+  return index;
+};
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
new file mode 100644 (file)
index 0000000..d2c8991
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * 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 type { TextRange, Issue } from '../../issue/types';
+
+export const getLinearLocations = (textRange?: TextRange): Array<{ line: number, from: number, to: number }> => {
+  if (!textRange) {
+    return [];
+  }
+  const locations = [];
+
+  // go through all lines of the `textRange`
+  for (let line = textRange.startLine; line <= textRange.endLine; line++) {
+    // TODO fix 999999
+    const from = line === textRange.startLine ? textRange.startOffset : 0;
+    const to = line === textRange.endLine ? textRange.endOffset : 999999;
+    locations.push({ line, from, to });
+  }
+  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 }) => {
+    if (locations) {
+      const locationsCount = locations.length;
+      locations.forEach((location, index) => {
+        const flowLocation = {
+          ...location,
+          // set index only for real flows, do not set for just secondary locations
+          index: locationsCount > 1 ? locationsCount - index : undefined
+        };
+        allLocations.push(flowLocation);
+      });
+    }
+  });
+  return allLocations;
+};
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js b/server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.js
new file mode 100644 (file)
index 0000000..ddc2963
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * 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 { searchIssues } from '../../../api/issues';
+import { parseIssueFromResponse } from '../../../helpers/issues';
+
+export type Query = { [string]: string };
+
+export type Issues = Array<*>;
+
+// maximum possible value
+const PAGE_SIZE = 500;
+
+const buildQuery = (component: string): Query => ({
+  additionalFields: '_all',
+  resolved: 'false',
+  componentKeys: component,
+  s: 'FILE_LINE'
+});
+
+export const loadPage = (query: Query, page: number, pageSize: number = PAGE_SIZE): Promise<Issues> => {
+  return searchIssues({ ...query, p: page, ps: pageSize }).then(r => (
+    r.issues.map(issue => parseIssueFromResponse(issue, r.components, r.users, r.rules))
+  ));
+};
+
+export const loadPageAndNext = (
+  query: Query,
+  toLine: number,
+  page: number,
+  pageSize: number = PAGE_SIZE
+): Promise<Issues> => {
+  return loadPage(query, page).then(issues => {
+    if (issues.length === 0) {
+      return [];
+    }
+
+    const lastIssue = issues[issues.length - 1];
+
+    if ((lastIssue.line != null && lastIssue.line > toLine) || issues.length < pageSize) {
+      return issues;
+    }
+
+    return loadPageAndNext(query, toLine, page + 1, pageSize).then(nextIssues => {
+      return [...issues, ...nextIssues];
+    });
+  });
+};
+
+const loadIssues = (component: string, fromLine: number, toLine: number): Promise<Issues> => {
+  const query = buildQuery(component);
+  return new Promise(resolve => {
+    loadPageAndNext(query, toLine, 1).then(issues => {
+      resolve(issues);
+    });
+  });
+};
+
+export default loadIssues;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/types.js b/server/sonar-web/src/main/js/components/SourceViewer/types.js
new file mode 100644 (file)
index 0000000..3dd00ee
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * 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
+export type SourceLine = {
+  code: string,
+  conditions?: number,
+  coverageStatus?: string | null,
+  coveredConditions?: number,
+  duplicated: boolean,
+  line: number,
+  lineHits?: number,
+  scmAuthor?: string,
+  scmDate?: string,
+  scmRevision?: string
+};
+
+export type Duplication = {
+  blocks: Array<{
+    _ref: string,
+    from: number,
+    size: number
+  }>
+};
index 363f0bdf72b331fb87282cba56f720f51dcf99c7..af7d22f632c1e9c3f4280a86c3709214d775a509 100644 (file)
@@ -25,22 +25,23 @@ export default Marionette.ItemView.extend({
 
   onRender () {
     this.$el.detach().appendTo($('body'));
+    const triggerEl = $(this.options.triggerEl);
     if (this.options.bottom) {
       this.$el.addClass('bubble-popup-bottom');
       this.$el.css({
-        top: this.options.triggerEl.offset().top + this.options.triggerEl.outerHeight(),
-        left: this.options.triggerEl.offset().left
+        top: triggerEl.offset().top + triggerEl.outerHeight(),
+        left: triggerEl.offset().left
       });
     } else if (this.options.bottomRight) {
       this.$el.addClass('bubble-popup-bottom-right');
       this.$el.css({
-        top: this.options.triggerEl.offset().top + this.options.triggerEl.outerHeight(),
-        right: $(window).width() - this.options.triggerEl.offset().left - this.options.triggerEl.outerWidth()
+        top: triggerEl.offset().top + triggerEl.outerHeight(),
+        right: $(window).width() - triggerEl.offset().left - triggerEl.outerWidth()
       });
     } else {
       this.$el.css({
-        top: this.options.triggerEl.offset().top,
-        left: this.options.triggerEl.offset().left + this.options.triggerEl.outerWidth()
+        top: triggerEl.offset().top,
+        left: triggerEl.offset().left + triggerEl.outerWidth()
       });
     }
     this.attachCloseEvents();
@@ -48,6 +49,7 @@ export default Marionette.ItemView.extend({
 
   attachCloseEvents () {
     const that = this;
+    const triggerEl = $(this.options.triggerEl);
     key('escape', () => {
       that.destroy();
     });
@@ -55,8 +57,8 @@ export default Marionette.ItemView.extend({
       $('body').off('click.bubble-popup');
       that.destroy();
     });
-    this.options.triggerEl.on('click.bubble-popup', e => {
-      that.options.triggerEl.off('click.bubble-popup');
+    triggerEl.on('click.bubble-popup', e => {
+      triggerEl.off('click.bubble-popup');
       e.stopPropagation();
       that.destroy();
     });
@@ -64,7 +66,7 @@ export default Marionette.ItemView.extend({
 
   onDestroy () {
     $('body').off('click.bubble-popup');
-    this.options.triggerEl.off('click.bubble-popup');
+    const triggerEl = $(this.options.triggerEl);
+    triggerEl.off('click.bubble-popup');
   }
 });
-
diff --git a/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js b/server/sonar-web/src/main/js/components/issue/ConnectedIssue.js
new file mode 100644 (file)
index 0000000..28be4c7
--- /dev/null
@@ -0,0 +1,29 @@
+/*
+ * 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 { connect } from 'react-redux';
+import Issue from './Issue';
+import { getIssueByKey } from '../../store/rootReducer';
+
+const mapStateToProps = (state, ownProps) => ({
+  issue: getIssueByKey(state, ownProps.issueKey)
+});
+
+export default connect(mapStateToProps)(Issue);
diff --git a/server/sonar-web/src/main/js/components/issue/Issue.js b/server/sonar-web/src/main/js/components/issue/Issue.js
new file mode 100644 (file)
index 0000000..c437b8f
--- /dev/null
@@ -0,0 +1,133 @@
+/*
+ * 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 { connect } from 'react-redux';
+import IssueView from './issue-view';
+import IssueModel from './models/issue';
+import { receiveIssues } from '../../store/issues/duck';
+import type { Issue as IssueType } from './types';
+
+type Model = { toJSON: () => {} };
+
+type Props = {
+  checked?: boolean,
+  issue: IssueType | Model,
+  onCheck?: () => void,
+  onClick: () => void,
+  onFilterClick?: () => void,
+  onIssueChange: ({}) => void,
+  selected: boolean
+};
+
+class Issue extends React.PureComponent {
+  issueView: Object;
+  node: HTMLElement;
+  props: Props;
+
+  componentDidMount () {
+    this.renderIssueView();
+    if (this.props.selected) {
+      this.bindShortcuts();
+    }
+  }
+
+  componentWillUpdate (nextProps: Props) {
+    if (!nextProps.selected && this.props.selected) {
+      this.unbindShortcuts();
+    }
+    this.destroyIssueView();
+  }
+
+  componentDidUpdate (prevProps: Props) {
+    this.renderIssueView();
+    if (!prevProps.selected && this.props.selected) {
+      this.bindShortcuts();
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.props.selected) {
+      this.unbindShortcuts();
+    }
+    this.destroyIssueView();
+  }
+
+  bindShortcuts () {
+    document.addEventListener('keypress', this.handleKeyPress);
+  }
+
+  unbindShortcuts () {
+    document.removeEventListener('keypress', this.handleKeyPress);
+  }
+
+  doIssueAction (action: string) {
+    this.issueView.$('.js-issue-' + action).click();
+  }
+
+  handleKeyPress = (e: Object) => {
+    const tagName = e.target.tagName.toUpperCase();
+    const shouldHandle = tagName !== 'INPUT' && tagName !== 'TEXTAREA' && tagName !== 'BUTTON';
+
+    if (shouldHandle) {
+      switch (e.key) {
+        case 'f': return this.doIssueAction('transition');
+        case 'a': return this.doIssueAction('assign');
+        case 'm': return this.doIssueAction('assign-to-me');
+        case 'p': return this.doIssueAction('plan');
+        case 'i': return this.doIssueAction('set-severity');
+        case 'c': return this.doIssueAction('comment');
+        case 't': return this.doIssueAction('edit-tags');
+      }
+    }
+  };
+
+  renderIssueView () {
+    const model = this.props.issue.toJSON ? this.props.issue : new IssueModel(this.props.issue);
+    this.issueView = new IssueView({
+      model,
+      checked: this.props.checked,
+      onCheck: this.props.onCheck,
+      onClick: this.props.onClick,
+      onFilterClick: this.props.onFilterClick,
+      onIssueChange: this.props.onIssueChange
+    });
+    this.issueView.render().$el.appendTo(this.node);
+    if (this.props.selected) {
+      this.issueView.select();
+    }
+  }
+
+  destroyIssueView () {
+    this.issueView.destroy();
+  }
+
+  render () {
+    return <div className="issue-container" ref={node => this.node = node}/>;
+  }
+}
+
+const onIssueChange = issue => dispatch => {
+  dispatch(receiveIssues([issue]));
+};
+
+const mapDispatchToProps = { onIssueChange };
+
+export default connect(null, mapDispatchToProps)(Issue);
index 71ced0ff47ab259b4608fb556b2dc735ecde907e..a4a691fa95582c0ceb6a5abc120e0b870f41aee1 100644 (file)
@@ -34,16 +34,21 @@ import Template from './templates/issue.hbs';
 import getCurrentUserFromStore from '../../app/utils/getCurrentUserFromStore';
 
 export default Marionette.ItemView.extend({
-  className: 'issue',
   template: Template,
 
   modelEvents: {
-    'change': 'render',
+    'change': 'notifyAndRender',
     'transition': 'onTransition'
   },
 
+  className () {
+    const hasCheckbox = this.options.onCheck != null;
+    return hasCheckbox ? 'issue issue-with-checkbox' : 'issue';
+  },
+
   events () {
     return {
+      'click': 'handleClick',
       'click .js-issue-comment': 'onComment',
       'click .js-issue-comment-edit': 'editComment',
       'click .js-issue-comment-delete': 'deleteComment',
@@ -56,10 +61,24 @@ export default Marionette.ItemView.extend({
       'click .js-issue-show-changelog': 'showChangeLog',
       'click .js-issue-rule': 'showRule',
       'click .js-issue-edit-tags': 'editTags',
-      'click .js-issue-locations': 'showLocations'
+      'click .js-issue-locations': 'showLocations',
+      'click .js-issue-filter': 'filterSimilarIssues',
+      'click .js-toggle': 'onIssueCheck'
     };
   },
 
+  notifyAndRender () {
+    const { onIssueChange } = this.options;
+    if (onIssueChange) {
+      onIssueChange(this.model.toJSON());
+    }
+
+    // if ConnectedIssue is used, this view can be destroyed just after onIssueChange()
+    if (!this.isDestroyed) {
+      this.render();
+    }
+  },
+
   onRender () {
     this.$el.attr('data-key', this.model.get('key'));
   },
@@ -243,19 +262,45 @@ export default Marionette.ItemView.extend({
     this.model.trigger('locations', this.model);
   },
 
+  select () {
+    this.$el.addClass('selected');
+  },
+
+  unselect () {
+    this.$el.removeClass('selected');
+  },
+
   onTransition (transition) {
     if (transition === 'falsepositive' || transition === 'wontfix') {
       this.comment({ fromTransition: true });
     }
   },
 
+  handleClick (e) {
+    e.preventDefault();
+    const { onClick } = this.options;
+    if (onClick) {
+      onClick(this.model.get('key'));
+    }
+  },
+
+  filterSimilarIssues (e) {
+    this.options.onFilterClick(e);
+  },
+
+  onIssueCheck (e) {
+    this.options.onCheck(e);
+  },
+
   serializeData () {
     const issueKey = encodeURIComponent(this.model.get('key'));
     return {
       ...Marionette.ItemView.prototype.serializeData.apply(this, arguments),
       permalink: window.baseUrl + '/issues/search#issues=' + issueKey,
-      hasSecondaryLocations: this.model.get('flows').length
+      hasSecondaryLocations: this.model.get('flows').length,
+      hasSimilarIssuesFilter: this.options.onFilterClick != null,
+      hasCheckbox: this.options.onCheck != null,
+      checked: this.options.checked
     };
   }
 });
-
index a828ecf5e3eb049e9be845bbbfc0d125a412aa9c..f951a40c0c4bf928d9c4c840cf6cd90393090650 100644 (file)
           <li class="issue-meta">
             <a class="js-issue-permalink icon-link" href="{{permalink}}" target="_blank"></a>
           </li>
+
+          {{#if hasSimilarIssuesFilter}}
+            <li class="issue-meta">
+              <button class="button-link issue-action issue-action-with-options js-issue-filter"
+                      aria-label="{{t "issue.filter_similar_issues"}}">
+                <i class="icon-filter icon-half-transparent"></i>&nbsp;<i class="icon-dropdown"></i>
+              </button>
+            </li>
+          {{/if}}
         </ul>
       </td>
     </tr>
   <i class="issue-navigate-to-left icon-chevron-left"></i>
   <i class="issue-navigate-to-right icon-chevron-right"></i>
 </a>
+
+{{#if hasCheckbox}}
+  <div class="js-toggle issue-checkbox-container">
+    <i class="issue-checkbox icon-checkbox {{#if checked}}icon-checkbox-checked{{/if}}"></i>
+  </div>
+{{/if}}
diff --git a/server/sonar-web/src/main/js/components/issue/types.js b/server/sonar-web/src/main/js/components/issue/types.js
new file mode 100644 (file)
index 0000000..dd0bbc1
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * 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
+export type TextRange = {
+  startLine: number,
+  startOffset: number,
+  endLine: number,
+  endOffset: number
+};
+
+export type Issue = {
+  key: string,
+  flows: Array<{
+    locations?: Array<{
+      msg: string,
+      textRange?: TextRange
+    }>
+  }>,
+  line?: number,
+  message: string,
+  severity: string,
+  textRange: TextRange
+};
diff --git a/server/sonar-web/src/main/js/components/shared/WithStore.js b/server/sonar-web/src/main/js/components/shared/WithStore.js
new file mode 100644 (file)
index 0000000..f3cb832
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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 getStore from '../../app/utils/getStore';
+
+export default class WithStore extends React.Component {
+  store: {};
+  props: { children: Object };
+
+  static childContextTypes = {
+    store: React.PropTypes.object
+  };
+
+  constructor (props: { children: Object }) {
+    super(props);
+    this.store = getStore();
+  }
+
+  getChildContext () {
+    return { store: this.store };
+  }
+
+  render () {
+    return this.props.children;
+  }
+}
diff --git a/server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js b/server/sonar-web/src/main/js/components/source-viewer/SourceViewer.js
deleted file mode 100644 (file)
index 0589bcd..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-/*
- * 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 React from 'react';
-import BaseSourceViewer from './main';
-import { getPeriodDate, getPeriodLabel } from '../../helpers/periods';
-
-export default class SourceViewer extends React.Component {
-  static propTypes = {
-    component: React.PropTypes.shape({
-      id: React.PropTypes.string.isRequired
-    }).isRequired,
-    period: React.PropTypes.object,
-    line: React.PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.string])
-  };
-
-  componentDidMount () {
-    this.renderSourceViewer();
-  }
-
-  shouldComponentUpdate (nextProps) {
-    return nextProps.component.id !== this.props.component.id;
-  }
-
-  componentWillUpdate () {
-    this.destroySourceViewer();
-  }
-
-  componentDidUpdate () {
-    this.renderSourceViewer();
-  }
-
-  componentWillUnmount () {
-    this.destroySourceViewer();
-  }
-
-  renderSourceViewer () {
-    this.sourceViewer = new BaseSourceViewer();
-    this.sourceViewer.render().$el.appendTo(this.refs.container);
-    this.sourceViewer.open(this.props.component.id);
-    this.sourceViewer.on('loaded', this.handleLoad.bind(this));
-  }
-
-  destroySourceViewer () {
-    this.sourceViewer.destroy();
-  }
-
-  handleLoad () {
-    const { period, line } = this.props;
-
-    if (period) {
-      const periodDate = getPeriodDate(period);
-      const periodLabel = getPeriodLabel(period);
-      this.sourceViewer.filterLinesByDate(periodDate, periodLabel);
-    }
-
-    if (line) {
-      this.sourceViewer.highlightLine(line);
-      this.sourceViewer.scrollToLine(line);
-    }
-  }
-
-  render () {
-    return <div ref="container"/>;
-  }
-}
index 58bfb9ce4f802b5498d8d8f791672de20c2d7f5a..8b1725d09f3d8820f07436383520edf8de6a2597 100644 (file)
@@ -21,7 +21,6 @@ import $ from 'jquery';
 import moment from 'moment';
 import sortBy from 'lodash/sortBy';
 import toPairs from 'lodash/toPairs';
-import Backbone from 'backbone';
 import Marionette from 'backbone.marionette';
 import Source from './source';
 import Issues from '../issue/collections/issues';
@@ -403,7 +402,7 @@ export default Marionette.LayoutView.extend({
     const row = this.model.get('source').find(row => row.line === line);
     const popup = new SCMPopupView({
       triggerEl: $(e.currentTarget),
-      model: new Backbone.Model(row)
+      line: row
     });
     popup.render();
   },
@@ -422,8 +421,8 @@ export default Marionette.LayoutView.extend({
     };
     return $.get(url, options).done(data => {
       const popup = new CoveragePopupView({
-        row,
-        collection: new Backbone.Collection(data.tests),
+        line: row,
+        tests: data.tests,
         triggerEl: $(e.currentTarget)
       });
       popup.render();
@@ -468,10 +467,11 @@ export default Marionette.LayoutView.extend({
       return isOk;
     });
     const popup = new DuplicationPopupView({
+      blocks,
       inRemovedComponent,
-      triggerEl: $(e.currentTarget),
-      model: this.model,
-      collection: new Backbone.Collection(blocks)
+      component: this.model.toJSON(),
+      files: this.model.get('duplicationFiles'),
+      triggerEl: $(e.currentTarget)
     });
     popup.render();
   },
@@ -498,8 +498,7 @@ export default Marionette.LayoutView.extend({
     const popup = new LineActionsPopupView({
       line,
       triggerEl: $(e.currentTarget),
-      model: this.model,
-      row: $(e.currentTarget).closest('.source-line')
+      component: this.model.toJSON()
     });
     popup.render();
   },
index a01d69b0f8550e4f18d1c767e7903b734545bb77..4baf170a2e8f81c79d5c9632976371340fa84e2f 100644 (file)
@@ -34,7 +34,7 @@ export default ModalView.extend({
   initialize () {
     this.testsScroll = 0;
     const requests = [this.requestMeasures(), this.requestIssues()];
-    if (this.model.get('isUnitTest')) {
+    if (this.model.get('q') === 'UTS') {
       requests.push(this.requestTests());
     }
     Promise.all(requests).then(() => this.render());
@@ -282,4 +282,3 @@ export default ModalView.extend({
     };
   }
 });
-
index 9b7181a64634f2d900dafddb1ea3b8f10007e8ac..aba02a8e1de7e378c4374c49bebaf73303bf5b31 100644 (file)
@@ -50,8 +50,8 @@ export default Marionette.ItemView.extend({
   },
 
   openInWorkspace () {
-    const uuid = this.options.parent.model.id;
-    Workspace.openComponent({ uuid });
+    const key = this.options.parent.model.get('key');
+    Workspace.openComponent({ key });
   },
 
   showRawSource () {
@@ -66,4 +66,3 @@ export default Marionette.ItemView.extend({
     };
   }
 });
-
index 1440241e42a7f527c68dadd95d5ac883ea1d34dc..68fd0ccc388c081738f2612c712ca8ca7e725d18 100644 (file)
@@ -27,7 +27,7 @@ export default Popup.extend({
   template: Template,
 
   events: {
-    'click a[data-id]': 'goToFile'
+    'click a[data-key]': 'goToFile'
   },
 
   onRender () {
@@ -37,19 +37,19 @@ export default Popup.extend({
 
   goToFile (e) {
     e.stopPropagation();
-    const id = $(e.currentTarget).data('id');
-    Workspace.openComponent({ uuid: id });
+    const key = $(e.currentTarget).data('key');
+    Workspace.openComponent({ key });
   },
 
   serializeData () {
-    const row = this.options.row || {};
-    const tests = groupBy(this.collection.toJSON(), 'fileId');
-    const testFiles = Object.keys(tests).map(fileId => {
-      const testSet = tests[fileId];
+    const row = this.options.line || {};
+    const tests = groupBy(this.options.tests, 'fileKey');
+    const testFiles = Object.keys(tests).map(fileKey => {
+      const testSet = tests[fileKey];
       const test = testSet[0];
       return {
         file: {
-          id: test.fileId,
+          key: test.fileKey,
           longName: test.fileName
         },
         tests: testSet
@@ -58,4 +58,3 @@ export default Popup.extend({
     return { testFiles, row };
   }
 });
-
index 24ad94fe25491648580357027e25b385844cb8b3..da542333a306fba01fb21441c99e7df643ecad9c 100644 (file)
@@ -28,37 +28,35 @@ export default Popup.extend({
   template: Template,
 
   events: {
-    'click a[data-uuid]': 'goToFile'
+    'click a[data-key]': 'goToFile'
   },
 
   goToFile (e) {
     e.stopPropagation();
-    const uuid = $(e.currentTarget).data('uuid');
+    const key = $(e.currentTarget).data('key');
     const line = $(e.currentTarget).data('line');
-    Workspace.openComponent({ uuid, line });
+    Workspace.openComponent({ key, line });
   },
 
   serializeData () {
     const that = this;
-    const files = this.model.get('duplicationFiles');
-    const groupedBlocks = groupBy(this.collection.toJSON(), '_ref');
+    const groupedBlocks = groupBy(this.options.blocks, '_ref');
     let duplications = Object.keys(groupedBlocks).map(fileRef => {
       return {
         blocks: groupedBlocks[fileRef],
-        file: files[fileRef]
+        file: this.options.files[fileRef]
       };
     });
     duplications = sortBy(duplications, d => {
-      const a = d.file.projectName !== that.model.get('projectName');
-      const b = d.file.subProjectName !== that.model.get('subProjectName');
-      const c = d.file.key !== that.model.get('key');
+      const a = d.file.projectName !== that.options.component.projectName;
+      const b = d.file.subProjectName !== that.options.component.subProjectName;
+      const c = d.file.key !== that.options.component.key;
       return '' + a + b + c;
     });
     return {
       duplications,
-      component: this.model.toJSON(),
+      component: this.options.component,
       inRemovedComponent: this.options.inRemovedComponent
     };
   }
 });
-
index aa89585bc44b2a81070241559e07e4bc311db932..a2d94f568b8131a3a8030cfd7ce309f26eb51018 100644 (file)
@@ -29,9 +29,9 @@ export default Popup.extend({
 
   getPermalink (e) {
     e.preventDefault();
-    const url =
-        `${window.baseUrl}/component/index?id=${encodeURIComponent(this.model.key())}&line=${this.options.line}`;
+    const { component, line } = this.options;
+    const url = `${window.baseUrl}/component/index?id=${encodeURIComponent(component.key)}&line=${line}`;
     const windowParams = 'resizable=1,scrollbars=1,status=1';
-    window.open(url, this.model.get('name'), windowParams);
+    window.open(url, component.name, windowParams);
   }
 });
index 755a866baec5472319f9c6501a16bd4774eff04a..f140e37c56bf540a1ee8258907b97a5dbcb0ccc7 100644 (file)
@@ -34,6 +34,12 @@ export default Popup.extend({
 
   onClick (e) {
     e.stopPropagation();
+  },
+
+  serializeData () {
+    return {
+      ...Popup.prototype.serializeData.apply(this, arguments),
+      line: this.options.line
+    };
   }
 });
-
index 43009061a22d608937ae83f26e36481c77bb26f2..3cb1198e3284cbea8582dc8a42e2aa253c216e91 100644 (file)
@@ -96,4 +96,3 @@ export default Backbone.Model.extend({
     return source.some(line => line.coverageStatus != null);
   }
 });
-
index a0e7b62896eed81cd5ffd357f3d8a0e9d84b7d46..57c6301119ec5fe8e9a41b1d58e083a9c4982697 100644 (file)
@@ -15,7 +15,7 @@
 
   {{#each testFiles}}
     <div class="bubble-popup-section">
-      <a class="component-viewer-popup-test-file link-action" data-id="{{file.id}}" title="{{file.longName}}">
+      <a class="component-viewer-popup-test-file link-action" data-key="{{file.key}}" title="{{file.longName}}">
         <span>{{collapsePath file.longName}}</span>
       </a>
       <ul class="bubble-popup-list">
@@ -24,7 +24,7 @@
             <i class="component-viewer-popup-test-status {{testStatusIconClass status}}"></i>
             <span class="component-viewer-popup-test-name">
               <a class="component-viewer-popup-test-file link-action" title="{{name}}"
-                 data-id="{{../file.id}}" data-method="{{name}}">
+                 data-key="{{../file.key}}" data-method="{{name}}">
                 {{name}}
               </a>
             </span>
index 9b0783c6655a30778d60a5c315596d8b6605e586..ea8fc2b2349956e1860f87e3aab95d3f715d532a 100644 (file)
@@ -21,7 +21,7 @@
 
           {{#notEq file.key ../component.key}}
             <div class="component-name-path">
-              <a class="link-action" data-uuid="{{file.uuid}}" title="{{file.name}}">
+              <a class="link-action" data-key="{{file.key}}" title="{{file.name}}">
                 <span>{{collapsedDirFromPath file.name}}</span><span
                   class="component-name-file">{{fileFromPath file.name}}</span>
               </a>
@@ -31,7 +31,7 @@
           <div class="component-name-path">
             Lines:
             {{#joinEach blocks ','}}
-              <a class="link-action" data-uuid="{{../file.uuid}}" data-line="{{this.from}}">
+              <a class="link-action" data-key="{{../file.key}}" data-line="{{this.from}}">
                 {{this.from}} â€“ {{sum from size -1}}
               </a>
             {{/joinEach}}
index e276c7e938b1c8dd01fda45514affce6d984eb75..a453235448180bf838c5cafe46d3271978eda9ad 100644 (file)
@@ -16,7 +16,7 @@
       <div class="component-name-path">
         {{qualifierIcon q}}&nbsp;<span>{{collapsedDirFromPath path}}</span><span class="component-name-file">{{fileFromPath path}}</span>
 
-        {{#if canMarkAsFavourite}}
+        {{#if canMarkAsFavorite}}
           <a class="js-favorite component-name-favorite {{#if fav}}icon-favorite{{else}}icon-not-favorite{{/if}}"
              title="{{#if fav}}{{t 'click_to_remove_from_favorites'}}{{else}}{{t 'click_to_add_to_favorites'}}{{/if}}">
           </a>
index a3f4df55605c95ed22e6d53af7e5f5eba31a394f..0df076390c9846fbdbb7ea965a2445ddb4c6a128 100644 (file)
     {{/unless}}
   </div>
 
-  {{#unless isUnitTest}}
+  {{#eq q 'UTS'}}
+    <div class="source-viewer-measures">
+      <div class="source-viewer-measures-section">
+        {{> 'measures/_source-viewer-measures-tests'}}
+      </div>
+    </div>
+    <div class="source-viewer-measures">
+      {{> 'measures/_source-viewer-measures-test-cases'}}
+    </div>
+  {{else}}
     <div class="source-viewer-measures">
       <div class="source-viewer-measures-section">
         <div class="source-viewer-measures-card">
         {{> 'measures/_source-viewer-measures-duplications'}}
       </div>
     </div>
-  {{else}}
-    <div class="source-viewer-measures">
-      <div class="source-viewer-measures-section">
-        {{> 'measures/_source-viewer-measures-tests'}}
-      </div>
-    </div>
-    <div class="source-viewer-measures">
-      {{> 'measures/_source-viewer-measures-test-cases'}}
-    </div>
-  {{/unless}}
+  {{/eq}}
 
 
   <div class="spacer-bottom">&nbsp;</div>
index 768ea72341dd8ff46dbf800139c053fc03d854b3..dd82aca528c7f0e115815f2124ab45197219e832 100644 (file)
@@ -1,13 +1,13 @@
 <div class="bubble-popup-container">
   <div class="bubble-popup-section">
-    {{scmAuthor}}
+    {{line.scmAuthor}}
   </div>
   <div class="bubble-popup-section">
-    {{dt scmDate}}
+    {{dt line.scmDate}}
   </div>
-  {{#if scmRevision}}
+  {{#if line.scmRevision}}
     <div class="bubble-popup-section">
-      {{scmRevision}}
+      {{line.scmRevision}}
     </div>
   {{/if}}
 </div>
index 30082332e4b2d807d2f380d5f293dfdf326fb747..4e1170bac3869e339f3198b02ba97d16a3dc5b9b 100644 (file)
@@ -99,7 +99,8 @@ Workspace.prototype = {
           that.closeComponentViewer();
           m.destroy();
         });
-    this.viewerView.render().$el.appendTo(document.body);
+    this.viewerView.$el.appendTo(document.body);
+    this.viewerView.render();
   },
 
   showComponentViewer (model) {
index 0ecbef4ac33744ebbcd77158545e5e3f1cd9f791..1dd6daf7fc5fb7cc0d85e15de74b8eb6cbb2302e 100644 (file)
@@ -25,8 +25,8 @@ export default Backbone.Model.extend({
     if (!this.has('__type__')) {
       return 'type is missing';
     }
-    if (this.get('__type__') === 'component' && !this.has('uuid')) {
-      return 'uuid is missing';
+    if (this.get('__type__') === 'component' && !this.has('key')) {
+      return 'key is missing';
     }
     if (this.get('__type__') === 'rule' && !this.has('key')) {
       return 'key is missing';
index 5d015e037ea12f2f4c6a9bcd52bced40adab65fb..97ff41e226733d614d1d4120fb7107788d57cd22 100644 (file)
@@ -47,16 +47,13 @@ export default Backbone.Collection.extend({
   },
 
   has (model) {
-    const forComponent = model.isComponent() && this.findWhere({ uuid: model.get('uuid') }) != null;
+    const forComponent = model.isComponent() && this.findWhere({ key: model.get('key') }) != null;
     const forRule = model.isRule() && this.findWhere({ key: model.get('key') }) != null;
     return forComponent || forRule;
   },
 
   add2 (model) {
-    const tryModel = model.isComponent() ?
-        this.findWhere({ uuid: model.get('uuid') }) :
-        this.findWhere({ key: model.get('key') });
+    const tryModel = this.findWhere({ key: model.get('key') });
     return tryModel != null ? tryModel : this.add(model);
   }
 });
-
index 924ea80ad7fbb28fd1cbf4c631627bd6a657e29f..7ab96e7c683ccf5bb6130b1052aac3d98b38e567 100644 (file)
  * 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 React from 'react';
+import { render } from 'react-dom';
 import BaseView from './base-viewer-view';
-import SourceViewer from '../../source-viewer/main';
+import SourceViewer from '../../SourceViewer/StandaloneSourceViewer';
 import Template from '../templates/workspace-viewer.hbs';
+import WithStore from '../../shared/WithStore';
 
 export default BaseView.extend({
   template: Template,
@@ -29,22 +33,39 @@ export default BaseView.extend({
     this.showViewer();
   },
 
-  showViewer () {
-    const that = this;
-    const viewer = new SourceViewer();
-    const options = this.model.toJSON();
-    viewer.open(this.model.get('uuid'), { workspace: true });
-    viewer.on('loaded', () => {
-      that.model.set({
-        name: viewer.model.get('name'),
-        q: viewer.model.get('q')
-      });
-      if (options.line != null) {
-        viewer.highlightLine(options.line);
-        viewer.scrollToLine(options.line);
+  scrollToLine (line) {
+    const row = this.$el.find(`.source-line[data-line-number="${line}"]`);
+    if (row.length > 0) {
+      const sourceViewer = this.$el.find('.source-viewer');
+      let p = sourceViewer.scrollParent();
+      if (p.is(document) || p.is('body')) {
+        p = $(window);
       }
-    });
-    this.viewerRegion.show(viewer);
+      const pTopOffset = p.offset() != null ? p.offset().top : 0;
+      const pHeight = p.height();
+      const goal = row.offset().top - pHeight / 3 - pTopOffset;
+      p.scrollTop(goal);
+    }
+  },
+
+  showViewer () {
+    const { key, line } = this.model.toJSON();
+
+    const el = document.querySelector(this.viewerRegion.el);
+
+    render((
+      <WithStore>
+        <SourceViewer
+          component={key}
+          fromWorkspace={true}
+          highlightedLine={line}
+          onLoaded={component => {
+            this.model.set({ name: component.name, q: component.q });
+            if (line) {
+              this.scrollToLine(line);
+            }
+          }}/>
+      </WithStore>
+    ), el);
   }
 });
-
diff --git a/server/sonar-web/src/main/js/helpers/issues.js b/server/sonar-web/src/main/js/helpers/issues.js
new file mode 100644 (file)
index 0000000..3a1e509
--- /dev/null
@@ -0,0 +1,121 @@
+/*
+ * 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 sortBy from 'lodash/sortBy';
+import { SEVERITIES } from './constants';
+
+type TextRange = {
+  startLine: number,
+  endLine: number,
+  startOffset: number,
+  endOffset: number
+};
+
+type Comment = {
+  login: string
+};
+
+type User = {
+  login: string
+};
+
+type RawIssue = {
+  assignee?: string,
+  author: string,
+  comments?: Array<Comment>,
+  component: string,
+  line?: number,
+  project: string,
+  rule: string,
+  status: string,
+  subProject?: string,
+  textRange?: TextRange
+};
+
+export const sortBySeverity = (issues: Array<*>) => (
+  sortBy(issues, issue => SEVERITIES.indexOf(issue.severity))
+);
+
+const injectRelational = (
+  issue: RawIssue | Comment,
+  source?: Array<*>,
+  baseField: string,
+  lookupField: string
+) => {
+  const newFields = {};
+  const baseValue = issue[baseField];
+  if (baseValue != null && source != null) {
+    const lookupValue = source.find(candidate => candidate[lookupField] === baseValue);
+    if (lookupValue != null) {
+      Object.keys(lookupValue).forEach(key => {
+        const newKey = baseField + key.charAt(0).toUpperCase() + key.slice(1);
+        newFields[newKey] = lookupValue[key];
+      });
+    }
+  }
+  return newFields;
+};
+
+const injectCommentsRelational = (issue: RawIssue, users?: Array<User>) => {
+  if (!issue.comments) {
+    return {};
+  }
+  const comments = issue.comments.map(comment => ({
+    ...comment,
+    author: comment.login,
+    login: undefined,
+    ...injectRelational(comment, users, 'author', 'login')
+  }));
+  return { comments };
+};
+
+const prepareClosed = (issue: RawIssue) => {
+  return issue.status === 'CLOSED' ? { flows: undefined } : {};
+};
+
+const ensureTextRange = (issue: RawIssue) => {
+  return issue.line && !issue.textRange ? {
+    textRange: {
+      startLine: issue.line,
+      endLine: issue.line,
+      startOffset: 0,
+      endOffset: 999999
+    }
+  } : {};
+};
+
+export const parseIssueFromResponse = (
+  issue: RawIssue,
+  components?: Array<*>,
+  users?: Array<*>,
+  rules?: Array<*>
+) => {
+  return {
+    ...issue,
+    ...injectRelational(issue, components, 'component', 'key'),
+    ...injectRelational(issue, components, 'project', 'key'),
+    ...injectRelational(issue, components, 'subProject', 'key'),
+    ...injectRelational(issue, rules, 'rule', 'key'),
+    ...injectRelational(issue, users, 'assignee', 'login'),
+    ...injectCommentsRelational(issue, users),
+    ...prepareClosed(issue),
+    ...ensureTextRange(issue)
+  };
+};
index 80bb9e787ccee47f3b439a5513da7f4100f4f340..cbd5a4e7c0198f2ed37dd12b1b7865568dead135 100644 (file)
@@ -146,19 +146,18 @@ export function request (url: string): Request {
  * @returns {*}
  */
 export function checkStatus (response: Response): Promise<Object> {
-  if (response.status === 401) {
-    // workaround cyclic dependencies
-    const handleRequiredAuthentication = require('../app/utils/handleRequiredAuthentication').default;
-    handleRequiredAuthentication();
-    return Promise.reject();
-  } else if (response.status >= 200 && response.status < 300) {
-    return Promise.resolve(response);
-  } else {
-    const error = new Error(response.status);
-    // $FlowFixMe complains that `response` is not found
-    error.response = response;
-    throw error;
-  }
+  return new Promise((resolve, reject) => {
+    if (response.status === 401) {
+      // workaround cyclic dependencies
+      const handleRequiredAuthentication = require('../app/utils/handleRequiredAuthentication').default;
+      handleRequiredAuthentication();
+      reject();
+    } else if (response.status >= 200 && response.status < 300) {
+      resolve(response);
+    } else {
+      reject({ response });
+    }
+  });
 }
 
 /**
index c97f715edbd2caabab79bb43e6e22b9b43ad09ba..ceeb119abfa8508e724a629438714a07bc6834b2 100644 (file)
  * 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 uniq from 'lodash/uniq';
 import without from 'lodash/without';
 
+type Favorite = { key: string };
+
+type ReceiveFavoritesAction = {
+  type: 'RECEIVE_FAVORITES',
+  favorites: Array<Favorite>,
+  notFavorites: Array<Favorite>
+};
+
+type AddFavoriteAction = {
+  type: 'ADD_FAVORITE',
+  componentKey: string
+};
+
+type RemoveFavoriteAction = {
+  type: 'REMOVE_FAVORITE',
+  componentKey: string
+};
+
+type Action = ReceiveFavoritesAction | AddFavoriteAction | RemoveFavoriteAction;
+
+type State = Array<string>;
+
 export const actions = {
   RECEIVE_FAVORITES: 'RECEIVE_FAVORITES',
   ADD_FAVORITE: 'ADD_FAVORITE',
   REMOVE_FAVORITE: 'REMOVE_FAVORITE'
 };
 
-export const receiveFavorites = (favorites, notFavorites = []) => ({
+export const receiveFavorites = (
+  favorites: Array<Favorite>,
+  notFavorites: Array<Favorite> = []
+): ReceiveFavoritesAction => ({
   type: actions.RECEIVE_FAVORITES,
   favorites,
   notFavorites
 });
 
-export const addFavorite = componentKey => ({
+export const addFavorite = (componentKey: string): AddFavoriteAction => ({
   type: actions.ADD_FAVORITE,
   componentKey
 });
 
-export const removeFavorite = componentKey => ({
+export const removeFavorite = (componentKey: string): RemoveFavoriteAction => ({
   type: actions.REMOVE_FAVORITE,
   componentKey
 });
 
-export default (state = [], action = {}) => {
+export default (state: State = [], action: Action): State => {
   if (action.type === actions.RECEIVE_FAVORITES) {
     const toAdd = action.favorites.map(f => f.key);
     const toRemove = action.notFavorites.map(f => f.key);
@@ -60,7 +86,6 @@ export default (state = [], action = {}) => {
   return state;
 };
 
-export const isFavorite = (state, componentKey) => (
+export const isFavorite = (state: State, componentKey: string) => (
     state.includes(componentKey)
 );
-
diff --git a/server/sonar-web/src/main/js/store/issues/duck.js b/server/sonar-web/src/main/js/store/issues/duck.js
new file mode 100644 (file)
index 0000000..1126bcf
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * 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 keyBy from 'lodash/keyBy';
+
+type Issue = { key: string };
+
+type ReceiveIssuesAction = {
+  type: 'RECEIVE_ISSUES',
+  issues: Array<Issue>
+};
+
+type Action = ReceiveIssuesAction;
+
+type State = { [key: string]: Issue };
+
+export const receiveIssues = (issues: Array<Issue>): ReceiveIssuesAction => ({
+  type: 'RECEIVE_ISSUES',
+  issues
+});
+
+const reducer = (state: State = {}, action: Action) => {
+  switch (action.type) {
+    case 'RECEIVE_ISSUES':
+      return { ...state, ...keyBy(action.issues, 'key') };
+    default:
+      return state;
+  }
+};
+
+export default reducer;
+
+export const getIssueByKey = (state: State, key: string): ?Issue => (
+  state[key]
+);
index aee309845c2e5ead300d74a8abc911efa6b2f4d3..1b8539f84ebb234c388dcacd120b1a571822ae5c 100644 (file)
@@ -22,6 +22,7 @@ import appState from './appState/duck';
 import components, * as fromComponents from './components/reducer';
 import users, * as fromUsers from './users/reducer';
 import favorites, * as fromFavorites from './favorites/duck';
+import issues, * as fromIssues from './issues/duck';
 import languages, * as fromLanguages from './languages/reducer';
 import measures, * as fromMeasures from './measures/reducer';
 import notifications, * as fromNotifications from './notifications/duck';
@@ -40,6 +41,7 @@ export default combineReducers({
   components,
   globalMessages,
   favorites,
+  issues,
   languages,
   measures,
   notifications,
@@ -80,6 +82,10 @@ export const isFavorite = (state, componentKey) => (
     fromFavorites.isFavorite(state.favorites, componentKey)
 );
 
+export const getIssueByKey = (state, key) => (
+  fromIssues.getIssueByKey(state.issues, key)
+);
+
 export const getComponentMeasure = (state, componentKey, metricKey) => (
     fromMeasures.getComponentMeasure(state.measures, componentKey, metricKey)
 );
index b56858fe641d3bb959598fb151da0c9d543bc585..8202664e4cc7747eac4da3459596c18a92091172 100644 (file)
@@ -50,7 +50,8 @@
   border-color: @issueBorderColor !important;
 }
 
-.issue + .issue {
+.issue + .issue,
+.issue-container + .issue-container {
   margin-top: 5px;
 }
 
index 18a2cfa5d1c2e418bcaca700c0b4b51cb1257c77..9a89d87959ac3a61410dda5b4ef362842e0f2023 100644 (file)
   user-select: none;
 }
 
+.source-meta:focus {
+  outline: none;
+}
+
+.source-meta[role="button"] {
+  cursor: pointer;
+}
+
 .source-meta + .source-meta {
   border-left: 1px solid @barBackgroundColor;
 }
   color: @secondFontColor;
   text-align: right;
 
-  &[data-line-number] {
-    cursor: pointer;
-  }
-
   &:before {
     content: attr(data-line-number);
   }
 .source-line-scm {
   padding: 0 5px;
   background-color: @barBackgroundColor;
-
-  &[data-line-number] {
-    cursor: pointer;
-  }
 }
 
 .source-line-scm-inner {
   height: @source-line-height;
 }
 
-.source-line-with-issues {
-  cursor: pointer;
-}
-
 .source-line-covered {
   background-color: @green !important;
-  cursor: pointer;
 }
 
 .source-line-uncovered {
   background-color: @red !important;
-  cursor: pointer;
 }
 
 .source-line-partially-covered {
   background-color: @orange !important;
   background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, .5) 4px, transparent 4px, transparent 8px, rgba(255, 255, 255, .5) 8px, rgba(255, 255, 255, .5) 12px, transparent 12px, transparent 16px, rgba(255, 255, 255, .5) 16px, rgba(255, 255, 255, .5) 20px) !important;
-  cursor: pointer;
 }
 
 .source-line-duplicated {
   background-color: @duplicationColor !important;
-  cursor: pointer;
 }
 
 
index fd1ddd7431e2eddd5b56ef035a8bc8c5330d9b1d..98bf375c2ea53e7c311ed2e169204015df2fda64 100644 (file)
   padding: 0 10px;
 }
 
-.issues-workspace-list-component + .issue {
+.issues-workspace-list-item + .issues-workspace-list-item {
+  margin-top: 5px;
+}
+
+.issues-workspace-list-component + .issues-workspace-list-item {
   margin-top: 10px;
 }
 
-.issue + .issues-workspace-list-component {
+.issues-workspace-list-item + .issues-workspace-list-component {
   margin-top: 25px;
 }
 
index 325bcad8177314540eabfc2db8b47454f6d0c4bd..760aec2845346ef7d05c250350f880c7d817cdc1 100644 (file)
   cursor: pointer;
 }
 .highlighted {
-  background-color: #B3D4FF;
+  background-color: #b3d4ff;
+  animation: highlightedFadeIn 0.3s forwards;
+}
+
+@keyframes highlightedFadeIn {
+  from { background-color: transparent; }
+  to { background-color: #b3d4ff; }
 }