]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11898 New codeviewer for multi-location issues (#1466)
authorJeremy <jeremy.davis@sonarsource.com>
Wed, 17 Apr 2019 16:31:50 +0000 (18:31 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 6 May 2019 09:01:15 +0000 (11:01 +0200)
Also includes SONAR-11901:
Add slim header for the issues page

37 files changed:
server/sonar-web/src/main/js/api/issues.ts
server/sonar-web/src/main/js/app/styles/components/component-name.css
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/apps/issues/__tests__/actions-test.ts
server/sonar-web/src/main/js/apps/issues/actions.ts
server/sonar-web/src/main/js/apps/issues/components/App.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/styles.css
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeader.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/components/Line.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap
server/sonar-web/src/main/js/components/SourceViewer/helpers/indexing.ts
server/sonar-web/src/main/js/components/SourceViewer/helpers/issueLocations.tsx
server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts [new file with mode: 0644]
server/sonar-web/src/main/js/components/SourceViewer/styles.css
server/sonar-web/src/main/js/components/common/LocationIndex.css
server/sonar-web/src/main/js/components/common/LocationMessage.css
server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/testMocks.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 27c1ccc513c0b36fce67981048a9f1dde620b07a..769512154d476d5eb3ca573a86be20e3b9ee7b32 100644 (file)
@@ -20,6 +20,7 @@
 import { getJSON, post, postJSON, RequestData } from '../helpers/request';
 import { RawIssue } from '../helpers/issues';
 import throwGlobalError from '../app/utils/throwGlobalError';
+import getCoverageStatus from '../components/SourceViewer/helpers/getCoverageStatus';
 
 export interface IssueResponse {
   components?: Array<{ key: string; name: string }>;
@@ -166,3 +167,21 @@ export function searchIssueAuthors(data: {
 }): Promise<string[]> {
   return getJSON('/api/issues/authors', data).then(r => r.authors, throwGlobalError);
 }
+
+export function getIssueFlowSnippets(issueKey: string): Promise<T.Dict<T.SnippetsByComponent>> {
+  return getJSON('/api/sources/issue_snippets', { issueKey }).then(result => {
+    Object.keys(result).forEach(k => {
+      if (result[k].sources) {
+        result[k].sources = result[k].sources.reduce(
+          (lineMap: T.Dict<T.SourceLine>, line: T.SourceLine) => {
+            line.coverageStatus = getCoverageStatus(line);
+            lineMap[line.line] = line;
+            return lineMap;
+          },
+          {}
+        );
+      }
+    });
+    return result;
+  }, throwGlobalError);
+}
index 30f78dc2cdaac45f96d08f9354c255c634788fde..bc6c11628eae96a1026589337ce0fd68bf35fc03 100644 (file)
@@ -52,8 +52,6 @@
 }
 
 .component-name-favorite {
-  position: relative;
-  top: -1px;
   margin-left: 4px;
-  padding: 2px 0;
+  padding: 0;
 }
index bbe084f42294ccb4c52250a07d1d17865d960edb..bae0eeefa7290b763c0074b08b273bc377b0eb2b 100644 (file)
@@ -57,6 +57,10 @@ th.nowrap {
   font-size: var(--smallFontSize);
 }
 
+.nudged-up {
+  margin-top: -1px;
+}
+
 .spacer-left {
   margin-left: 8px !important;
 }
index cc3d2a1c2ff06f15cc8a84b160f6b141d8ef6246..fe4c3c304190723746363a984a2e44ec4e374b8e 100644 (file)
@@ -273,6 +273,8 @@ declare namespace T {
 
   export type EditionKey = 'community' | 'developer' | 'enterprise' | 'datacenter';
 
+  export type ExpandDirection = 'up' | 'down';
+
   export interface Extension {
     key: string;
     name: string;
@@ -286,6 +288,7 @@ declare namespace T {
   export interface FlowLocation {
     component: string;
     componentName?: string;
+    index?: number;
     msg?: string;
     textRange: TextRange;
   }
@@ -400,6 +403,9 @@ declare namespace T {
 
   export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT';
 
+  export interface IssuesByLine {
+    [key: number]: Issue[];
+  }
   export interface Language {
     key: string;
     name: string;
@@ -418,9 +424,14 @@ declare namespace T {
     index?: number;
     line: number;
     startLine?: number;
+    text?: string;
     to: number;
   }
 
+  export interface LineMap {
+    [line: number]: SourceLine;
+  }
+
   export interface LoggedInUser extends CurrentUser {
     avatar?: string;
     email?: string;
@@ -794,6 +805,14 @@ declare namespace T {
     type: 'SHORT';
   }
 
+  export interface SnippetGroup extends SnippetsByComponent {
+    locations: T.FlowLocation[];
+  }
+  export interface SnippetsByComponent {
+    component: SourceViewerFile;
+    sources: { [line: number]: SourceLine };
+  }
+
   export interface SourceLine {
     code?: string;
     conditions?: number;
index adf9389ca7a53e1b288f0fccf5fc57bd33e4624f..bead280986a2f9d83c42996b469bd9e4ee5b9f63 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 { selectFlow } from '../actions';
 
-it('should select flow and enable locations navigator', () => {
-  expect(selectFlow(5)()).toEqual({
-    locationsNavigator: true,
-    selectedFlowIndex: 5,
-    selectedLocationIndex: 0
+import { selectFlow, selectLocation } from '../actions';
+import { mockIssue } from '../../../helpers/testMocks';
+
+describe('selectFlow', () => {
+  it('should select flow and enable locations navigator', () => {
+    expect(selectFlow(5)()).toEqual({
+      locationsNavigator: true,
+      selectedFlowIndex: 5,
+      selectedLocationIndex: 0
+    });
+  });
+});
+
+describe('selectLocation', () => {
+  it('should select location and enable locations navigator', () => {
+    expect(selectLocation(5)({ openIssue: mockIssue() })).toEqual({
+      locationsNavigator: true,
+      selectedLocationIndex: 5
+    });
+  });
+
+  it('should deselect location when clicked again', () => {
+    expect(selectLocation(5)({ openIssue: mockIssue(), selectedLocationIndex: 5 })).toEqual({
+      locationsNavigator: false,
+      selectedLocationIndex: undefined
+    });
+  });
+
+  it('should ignore if no open issue', () => {
+    expect(selectLocation(5)({ openIssue: undefined })).toBeNull();
   });
 });
index 937aee317465bb265de94a63ebddd40cd53d85e3..7a8b79f01796caa5f01189cd0043ed0ae2708916 100644 (file)
@@ -43,27 +43,27 @@ export function disableLocationsNavigator() {
   return { locationsNavigator: false };
 }
 
-export function selectLocation(nextIndex: number | undefined) {
-  return (state: State) => {
+export function selectLocation(nextIndex: number) {
+  return (state: Pick<State, 'selectedLocationIndex' | 'openIssue'>) => {
     const { selectedLocationIndex: index, openIssue } = state;
     if (openIssue) {
-      if (!state.locationsNavigator) {
-        if (nextIndex !== undefined) {
-          return { locationsNavigator: true, selectedLocationIndex: nextIndex };
-        }
-      } else if (index !== undefined) {
+      if (index === nextIndex) {
         // disable locations when selecting (clicking) the same location
         return {
-          locationsNavigator: nextIndex !== index,
-          selectedLocationIndex: nextIndex
+          locationsNavigator: false,
+          selectedLocationIndex: undefined
         };
+      } else {
+        return { locationsNavigator: true, selectedLocationIndex: nextIndex };
       }
     }
     return null;
   };
 }
 
-export function selectNextLocation(state: State) {
+export function selectNextLocation(
+  state: Pick<State, 'selectedFlowIndex' | 'selectedLocationIndex' | 'openIssue'>
+) {
   const { selectedFlowIndex, selectedLocationIndex: index, openIssue } = state;
   if (openIssue) {
     const locations =
index 0b7307f2ce50f024602789581cd0904dd8034f7e..d9f9ea8607f064d28c6870cd37e8d52889e387d7 100644 (file)
@@ -23,7 +23,6 @@ import * as key from 'keymaster';
 import Helmet from 'react-helmet';
 import { keyBy, omit, without } from 'lodash';
 import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal';
-import ComponentBreadcrumbs from './ComponentBreadcrumbs';
 import IssuesList from './IssuesList';
 import IssuesSourceViewer from './IssuesSourceViewer';
 import MyIssuesFilter from './MyIssuesFilter';
@@ -802,7 +801,7 @@ export class App extends React.PureComponent<Props, State> {
     );
   };
 
-  selectLocation = (index?: number) => {
+  selectLocation = (index: number) => {
     this.setState(actions.selectLocation(index));
   };
 
@@ -1036,13 +1035,49 @@ export class App extends React.PureComponent<Props, State> {
     );
   }
 
+  renderHeader({
+    openIssue,
+    paging,
+    selectedIndex
+  }: {
+    openIssue: T.Issue | undefined;
+    paging: T.Paging | undefined;
+    selectedIndex: number | undefined;
+  }) {
+    return openIssue ? (
+      <A11ySkipTarget anchor="issues_main" />
+    ) : (
+      <div className="layout-page-header-panel layout-page-main-header issues-main-header">
+        <div className="layout-page-header-panel-inner layout-page-main-header-inner">
+          <div className="layout-page-main-inner">
+            <A11ySkipTarget anchor="issues_main" />
+
+            {this.renderBulkChange(openIssue)}
+            <PageActions
+              canSetHome={Boolean(
+                !this.props.organization &&
+                  !this.props.component &&
+                  (!isSonarCloud() || this.props.myIssues)
+              )}
+              effortTotal={this.state.effortTotal}
+              onReload={this.handleReload}
+              paging={paging}
+              selectedIndex={selectedIndex}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
   renderPage() {
-    const { checkAll, loading, openIssue, paging } = this.state;
+    const { checkAll, issues, loading, openIssue, paging } = this.state;
     return (
       <div className="layout-page-main-inner">
         {openIssue ? (
           <IssuesSourceViewer
             branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
+            issues={issues}
             loadIssues={this.fetchIssuesForComponent}
             locationsNavigator={this.state.locationsNavigator}
             onIssueChange={this.handleIssueChange}
@@ -1071,7 +1106,6 @@ export class App extends React.PureComponent<Props, State> {
   }
 
   render() {
-    const { component } = this.props;
     const { openIssue, paging } = this.state;
     const selectedIndex = this.getSelectedIndex();
     return (
@@ -1082,38 +1116,7 @@ export class App extends React.PureComponent<Props, State> {
         {this.renderSide(openIssue)}
 
         <div className="layout-page-main">
-          <div className="layout-page-header-panel layout-page-main-header issues-main-header">
-            <div className="layout-page-header-panel-inner layout-page-main-header-inner">
-              <div className="layout-page-main-inner">
-                <A11ySkipTarget anchor="issues_main" />
-
-                {this.renderBulkChange(openIssue)}
-                {openIssue ? (
-                  <div className="pull-left width-60">
-                    <ComponentBreadcrumbs
-                      component={component}
-                      issue={openIssue}
-                      organization={this.props.organization}
-                      selectedFlowIndex={this.state.selectedFlowIndex}
-                      selectedLocationIndex={this.state.selectedLocationIndex}
-                    />
-                  </div>
-                ) : (
-                  <PageActions
-                    canSetHome={Boolean(
-                      !this.props.organization &&
-                        !this.props.component &&
-                        (!isSonarCloud() || this.props.myIssues)
-                    )}
-                    effortTotal={this.state.effortTotal}
-                    onReload={this.handleReload}
-                    paging={paging}
-                    selectedIndex={selectedIndex}
-                  />
-                )}
-              </div>
-            </div>
-          </div>
+          {this.renderHeader({ openIssue, paging, selectedIndex })}
 
           {this.renderPage()}
         </div>
index 3e2d6ee44c77b034ef030bbe600c3c62eab3a6d2..2845950c8ac1df2e28a8d0a445d2447ba61f6f66 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { uniq } from 'lodash';
 import { getLocations, getSelectedLocation } from '../utils';
-import SourceViewer from '../../../components/SourceViewer/SourceViewer';
 import { scrollToElement } from '../../../helpers/scrolling';
+import CrossComponentSourceViewer from '../crossComponentSourceViewer/CrossComponentSourceViewer';
+import SourceViewer from '../../../components/SourceViewer/SourceViewer';
 
 interface Props {
   branchLike: T.BranchLike | undefined;
+  issues: T.Issue[];
   loadIssues: (component: string, from: number, to: number) => Promise<T.Issue[]>;
   locationsNavigator: boolean;
   onIssueChange: (issue: T.Issue) => void;
@@ -72,57 +75,77 @@ export default class IssuesSourceViewer extends React.PureComponent<Props> {
   render() {
     const { openIssue, selectedFlowIndex, selectedLocationIndex } = this.props;
 
-    const locations = getLocations(openIssue, selectedFlowIndex);
+    const locations = getLocations(openIssue, selectedFlowIndex).map((loc, index) => {
+      loc.index = index;
+      return loc;
+    });
     const selectedLocation = getSelectedLocation(
       openIssue,
       selectedFlowIndex,
       selectedLocationIndex
     );
 
-    const component = selectedLocation ? selectedLocation.component : openIssue.component;
-
-    // if location is selected, show (and load) code around it
-    // otherwise show code around the open issue
-    const aroundLine = selectedLocation
-      ? selectedLocation.textRange.startLine
-      : openIssue.textRange && openIssue.textRange.endLine;
-
-    // replace locations in another file with `undefined` to keep the same location indexes
-    const highlightedLocations = locations.map(location =>
-      location.component === component ? location : undefined
-    );
-
     const highlightedLocationMessage =
       this.props.locationsNavigator && selectedLocationIndex !== undefined
         ? selectedLocation && { index: selectedLocationIndex, text: selectedLocation.msg }
         : undefined;
 
-    const allMessagesEmpty = locations !== undefined && locations.every(location => !location.msg);
-
-    // do not load issues when open another file for a location
-    const loadIssues =
-      component === openIssue.component ? this.props.loadIssues : () => Promise.resolve([]);
-    const selectedIssue = component === openIssue.component ? openIssue.key : undefined;
-
-    return (
-      <div ref={node => (this.node = node)}>
-        <SourceViewer
-          aroundLine={aroundLine}
-          branchLike={this.props.branchLike}
-          component={component}
-          displayAllIssues={true}
-          displayLocationMarkers={!allMessagesEmpty}
-          highlightedLocationMessage={highlightedLocationMessage}
-          highlightedLocations={highlightedLocations}
-          loadIssues={loadIssues}
-          onIssueChange={this.props.onIssueChange}
-          onIssueSelect={this.props.onIssueSelect}
-          onLoaded={this.handleLoaded}
-          onLocationSelect={this.props.onLocationSelect}
-          scroll={this.handleScroll}
-          selectedIssue={selectedIssue}
-        />
-      </div>
-    );
+    if (locations.length > 1) {
+      const components = uniq(locations.map(l => l.component));
+      return (
+        <div ref={node => (this.node = node)}>
+          <CrossComponentSourceViewer
+            branchLike={this.props.branchLike}
+            components={components}
+            highlightedLocationMessage={highlightedLocationMessage}
+            issue={openIssue}
+            issues={this.props.issues}
+            locations={locations}
+            onIssueChange={this.props.onIssueChange}
+            onLoaded={this.handleLoaded}
+            onLocationSelect={this.props.onLocationSelect}
+            scroll={this.handleScroll}
+            selectedFlowIndex={selectedFlowIndex}
+          />
+        </div>
+      );
+    } else {
+      // if location is selected, show (and load) code around it
+      // otherwise show code around the open issue
+      const aroundLine = selectedLocation
+        ? selectedLocation.textRange.startLine
+        : openIssue.textRange && openIssue.textRange.endLine;
+
+      const component = selectedLocation ? selectedLocation.component : openIssue.component;
+
+      const highlightedLocations = locations.filter(location => location.component === component);
+
+      // do not load issues when open another file for a location
+      const loadIssues =
+        component === openIssue.component ? this.props.loadIssues : () => Promise.resolve([]);
+      const selectedIssue = component === openIssue.component ? openIssue.key : undefined;
+
+      return (
+        <div ref={node => (this.node = node)}>
+          <SourceViewer
+            aroundLine={aroundLine}
+            branchLike={this.props.branchLike}
+            component={component}
+            displayAllIssues={true}
+            displayLocationMarkers={false}
+            highlightedLocationMessage={highlightedLocationMessage}
+            highlightedLocations={highlightedLocations}
+            loadIssues={loadIssues}
+            onIssueChange={this.props.onIssueChange}
+            onIssueSelect={this.props.onIssueSelect}
+            onLoaded={this.handleLoaded}
+            onLocationSelect={this.props.onLocationSelect}
+            scroll={this.handleScroll}
+            selectedIssue={selectedIssue}
+            slimHeader={true}
+          />
+        </div>
+      );
+    }
   }
 }
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx
new file mode 100644 (file)
index 0000000..9e5c90a
--- /dev/null
@@ -0,0 +1,361 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import {
+  createSnippets,
+  expandSnippet,
+  inSnippet,
+  EXPAND_BY_LINES,
+  LINES_BELOW_LAST,
+  MERGE_DISTANCE
+} from './utils';
+import { getSources } from '../../../api/components';
+import ExpandSnippetIcon from '../../../components/icons-components/ExpandSnippetIcon';
+import Line from '../../../components/SourceViewer/components/Line';
+import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
+import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
+import { symbolsByLine, locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
+import { getSecondaryIssueLocationsForLine } from '../../../components/SourceViewer/helpers/issueLocations';
+import {
+  optimizeLocationMessage,
+  optimizeHighlightedSymbols,
+  optimizeSelectedIssue
+} from '../../../components/SourceViewer/helpers/lines';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  branchLike: T.BranchLike | undefined;
+  highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
+  issue: T.Issue;
+  issuePopup?: { issue: string; name: string };
+  issuesByLine: T.IssuesByLine;
+  last: boolean;
+  locations: T.FlowLocation[];
+  onIssueChange: (issue: T.Issue) => void;
+  onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
+  onLocationSelect: (index: number) => void;
+  renderDuplicationPopup: (index: number, line: number) => JSX.Element;
+  scroll?: (element: HTMLElement) => void;
+  snippetGroup: T.SnippetGroup;
+}
+
+interface State {
+  additionalLines: { [line: number]: T.SourceLine };
+  highlightedSymbols: string[];
+  loading: boolean;
+  openIssuesByLine: T.Dict<boolean>;
+  snippets: T.SourceLine[][];
+}
+
+export default class ComponentSourceSnippetViewer extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {
+    additionalLines: {},
+    highlightedSymbols: [],
+    loading: false,
+    openIssuesByLine: {},
+    snippets: []
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.createSnippetsFromProps();
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  createSnippetsFromProps() {
+    const mainLocation: T.FlowLocation = {
+      component: this.props.issue.component,
+      textRange: this.props.issue.textRange || {
+        endLine: 0,
+        endOffset: 0,
+        startLine: 0,
+        startOffset: 0
+      }
+    };
+    const snippets = createSnippets(
+      this.props.snippetGroup.locations.concat(mainLocation),
+      this.props.snippetGroup.sources,
+      this.props.last
+    );
+    this.setState({ snippets });
+  }
+
+  expandBlock = (snippetIndex: number, direction: T.ExpandDirection) => {
+    const { snippets } = this.state;
+
+    const snippet = snippets[snippetIndex];
+
+    // Extend by EXPAND_BY_LINES and add buffer for merging snippets
+    const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
+
+    const range =
+      direction === 'up'
+        ? {
+            from: Math.max(1, snippet[0].line - extension),
+            to: snippet[0].line - 1
+          }
+        : {
+            from: snippet[snippet.length - 1].line + 1,
+            to: snippet[snippet.length - 1].line + extension
+          };
+
+    getSources({
+      key: this.props.snippetGroup.component.key,
+      ...range
+    })
+      .then(lines =>
+        lines.reduce((lineMap: T.Dict<T.SourceLine>, line) => {
+          line.coverageStatus = getCoverageStatus(line);
+          lineMap[line.line] = line;
+          return lineMap;
+        }, {})
+      )
+      .then(
+        newLinesMapped => {
+          if (this.mounted) {
+            this.setState(({ additionalLines, snippets }) => {
+              const combinedLines = { ...additionalLines, ...newLinesMapped };
+
+              return {
+                additionalLines: combinedLines,
+                snippets: expandSnippet({
+                  direction,
+                  lines: { ...combinedLines, ...this.props.snippetGroup.sources },
+                  snippetIndex,
+                  snippets
+                })
+              };
+            });
+          }
+        },
+        () => null
+      );
+  };
+
+  expandComponent = () => {
+    const { key } = this.props.snippetGroup.component;
+
+    this.setState({ loading: true });
+
+    getSources({ key }).then(
+      lines => {
+        if (this.mounted) {
+          this.setState({ loading: false, snippets: [lines] });
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  };
+
+  handleOpenIssues = (line: T.SourceLine) => {
+    this.setState(state => ({
+      openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true }
+    }));
+  };
+
+  handleCloseIssues = (line: T.SourceLine) => {
+    this.setState(state => ({
+      openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false }
+    }));
+  };
+
+  renderLine({
+    index,
+    issuesForLine,
+    issueLocations,
+    line,
+    snippet,
+    symbols,
+    verticalBuffer
+  }: {
+    index: number;
+    issuesForLine: T.Issue[];
+    issueLocations: T.LinearIssueLocation[];
+    line: T.SourceLine;
+    snippet: T.SourceLine[];
+    symbols: string[];
+    verticalBuffer: number;
+  }) {
+    const { openIssuesByLine } = this.state;
+
+    const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, this.props.locations);
+
+    const noop = () => {};
+
+    const isSinkLine = issuesForLine.some(i => i.key === this.props.issue.key);
+
+    return (
+      <Line
+        branchLike={undefined}
+        displayAllIssues={false}
+        displayCoverage={true}
+        displayDuplications={false}
+        displayIssues={!isSinkLine || issuesForLine.length > 1}
+        displayLocationMarkers={true}
+        duplications={[]}
+        duplicationsCount={0}
+        highlighted={false}
+        highlightedLocationMessage={optimizeLocationMessage(
+          this.props.highlightedLocationMessage,
+          secondaryIssueLocations
+        )}
+        highlightedSymbols={optimizeHighlightedSymbols(symbols, this.state.highlightedSymbols)}
+        issueLocations={issueLocations}
+        issuePopup={this.props.issuePopup}
+        issues={issuesForLine}
+        key={line.line}
+        last={false}
+        line={line}
+        linePopup={undefined}
+        loadDuplications={noop}
+        onIssueChange={this.props.onIssueChange}
+        onIssuePopupToggle={this.props.onIssuePopupToggle}
+        onIssueSelect={noop}
+        onIssueUnselect={noop}
+        onIssuesClose={this.handleCloseIssues}
+        onIssuesOpen={this.handleOpenIssues}
+        onLinePopupToggle={noop}
+        onLocationSelect={this.props.onLocationSelect}
+        onSymbolClick={highlightedSymbols => this.setState({ highlightedSymbols })}
+        openIssues={openIssuesByLine[line.line]}
+        previousLine={index > 0 ? snippet[index - 1] : undefined}
+        renderDuplicationPopup={this.props.renderDuplicationPopup}
+        scroll={this.props.scroll}
+        secondaryIssueLocations={secondaryIssueLocations}
+        selectedIssue={optimizeSelectedIssue(this.props.issue.key, issuesForLine)}
+        verticalBuffer={verticalBuffer}
+      />
+    );
+  }
+
+  renderSnippet({
+    snippet,
+    index,
+    issue,
+    issuesByLine = {},
+    locationsByLine,
+    last
+  }: {
+    snippet: T.SourceLine[];
+    index: number;
+    issue: T.Issue;
+    issuesByLine: T.IssuesByLine;
+    locationsByLine: { [line: number]: T.LinearIssueLocation[] };
+    last: boolean;
+  }) {
+    const { component } = this.props.snippetGroup;
+    const lastLine =
+      component.measures && component.measures.lines && parseInt(component.measures.lines, 10);
+
+    const symbols = symbolsByLine(snippet);
+
+    const expandBlock = (direction: T.ExpandDirection) => () => this.expandBlock(index, direction);
+
+    const bottomLine = snippet[snippet.length - 1].line;
+    const issueLine = issue.textRange ? issue.textRange.endLine : issue.line;
+    const lowestVisibleIssue = Math.max(
+      ...Object.keys(issuesByLine)
+        .map(k => parseInt(k, 10))
+        .filter(l => inSnippet(l, snippet) && (l === issueLine || this.state.openIssuesByLine[l]))
+    );
+    const verticalBuffer = last
+      ? Math.max(0, LINES_BELOW_LAST - (bottomLine - lowestVisibleIssue))
+      : 0;
+
+    return (
+      <div className="source-viewer-code snippet" key={index}>
+        {snippet[0].line > 1 && (
+          <button
+            aria-label={translate('source_viewer.expand_above')}
+            className="expand-block expand-block-above"
+            onClick={expandBlock('up')}
+            type="button">
+            <ExpandSnippetIcon />
+          </button>
+        )}
+        <table className="source-table">
+          <tbody>
+            {snippet.map((line, index) =>
+              this.renderLine({
+                index,
+                issuesForLine: issuesByLine[line.line] || [],
+                issueLocations: locationsByLine[line.line] || [],
+                line,
+                snippet,
+                symbols: symbols[line.line],
+                verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0
+              })
+            )}
+          </tbody>
+        </table>
+        {(!lastLine || snippet[snippet.length - 1].line < lastLine) && (
+          <button
+            aria-label={translate('source_viewer.expand_below')}
+            className="expand-block expand-block-below"
+            onClick={expandBlock('down')}
+            type="button">
+            <ExpandSnippetIcon />
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  render() {
+    const { branchLike, issue, issuesByLine, last, snippetGroup } = this.props;
+    const { loading, snippets } = this.state;
+    const locations = locationsByLine([issue]);
+
+    const fullyShown =
+      snippets.length === 1 &&
+      snippetGroup.component.measures &&
+      snippets[0].length === parseInt(snippetGroup.component.measures.lines || '', 10);
+
+    return (
+      <div className="component-source-container">
+        <SourceViewerHeaderSlim
+          branchLike={branchLike}
+          expandable={!fullyShown}
+          loading={loading}
+          onExpand={this.expandComponent}
+          sourceViewerFile={snippetGroup.component}
+        />
+        {snippets.map((snippet, index) =>
+          this.renderSnippet({
+            snippet,
+            index,
+            issue,
+            issuesByLine: last ? issuesByLine : {},
+            locationsByLine: last && index === snippets.length - 1 ? locations : {},
+            last: last && index === snippets.length - 1
+          })
+        )}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
new file mode 100644 (file)
index 0000000..ff48d1e
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { lazyLoad } from '../../../components/lazyLoad';
+
+const CrossComponentSourceViewer = lazyLoad(() =>
+  import(/* webpackPrefetch: true */ './CrossComponentSourceViewerWrapper')
+);
+
+export default CrossComponentSourceViewer;
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx
new file mode 100644 (file)
index 0000000..6f27127
--- /dev/null
@@ -0,0 +1,138 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import ComponentSourceSnippetViewer from './ComponentSourceSnippetViewer';
+import { groupLocationsByComponent } from './utils';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import { getIssueFlowSnippets } from '../../../api/issues';
+import { issuesByComponentAndLine } from '../../../components/SourceViewer/helpers/indexing';
+
+interface State {
+  components: T.Dict<T.SnippetsByComponent>;
+  issuePopup?: { issue: string; name: string };
+  loading: boolean;
+}
+
+interface Props {
+  branchLike: T.Branch | T.PullRequest | undefined;
+  highlightedLocationMessage?: { index: number; text: string | undefined };
+  issue: T.Issue;
+  issues: T.Issue[];
+  locations: T.FlowLocation[];
+  onIssueChange: (issue: T.Issue) => void;
+  onLoaded?: () => void;
+  onLocationSelect: (index: number) => void;
+  renderDuplicationPopup: (index: number, line: number) => JSX.Element;
+  scroll?: (element: HTMLElement) => void;
+  selectedFlowIndex: number | undefined;
+}
+
+export default class CrossComponentSourceViewerWrapper extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {
+    components: {},
+    loading: true
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    this.fetchIssueFlowSnippets(this.props.issue.key);
+  }
+
+  componentWillReceiveProps(newProps: Props) {
+    if (newProps.issue.key !== this.props.issue.key) {
+      this.fetchIssueFlowSnippets(newProps.issue.key);
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchIssueFlowSnippets(issueKey: string) {
+    this.setState({ loading: true });
+    getIssueFlowSnippets(issueKey).then(
+      components => {
+        if (this.mounted) {
+          this.setState({ components, issuePopup: undefined, loading: false });
+          if (this.props.onLoaded) {
+            this.props.onLoaded();
+          }
+        }
+      },
+      () => {
+        if (this.mounted) {
+          this.setState({ loading: false });
+        }
+      }
+    );
+  }
+
+  handleIssuePopupToggle = (issue: string, popupName: string, open?: boolean) => {
+    this.setState((state: State) => {
+      const samePopup =
+        state.issuePopup && state.issuePopup.name === popupName && state.issuePopup.issue === issue;
+      if (open !== false && !samePopup) {
+        return { issuePopup: { issue, name: popupName } };
+      } else if (open !== true && samePopup) {
+        return { issuePopup: undefined };
+      }
+      return null;
+    });
+  };
+
+  render() {
+    const { components, loading } = this.state;
+
+    if (loading) {
+      return (
+        <div>
+          <DeferredSpinner />
+        </div>
+      );
+    }
+
+    const issuesByComponent = issuesByComponentAndLine(this.props.issues);
+    const locationsByComponent = groupLocationsByComponent(this.props.locations, components);
+
+    return (
+      <div>
+        {locationsByComponent.map((g, i) => (
+          <ComponentSourceSnippetViewer
+            branchLike={this.props.branchLike}
+            highlightedLocationMessage={this.props.highlightedLocationMessage}
+            issue={this.props.issue}
+            issuePopup={this.state.issuePopup}
+            issuesByLine={issuesByComponent[g.component.key] || {}}
+            key={this.props.issue.key + '-' + this.props.selectedFlowIndex + '-' + i}
+            last={i === locationsByComponent.length - 1}
+            locations={g.locations || []}
+            onIssueChange={this.props.onIssueChange}
+            onIssuePopupToggle={this.handleIssuePopupToggle}
+            onLocationSelect={this.props.onLocationSelect}
+            renderDuplicationPopup={this.props.renderDuplicationPopup}
+            scroll={this.props.scroll}
+            snippetGroup={g}
+          />
+        ))}
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx
new file mode 100644 (file)
index 0000000..6076ef4
--- /dev/null
@@ -0,0 +1,98 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import ComponentSourceSnippetViewer from '../ComponentSourceSnippetViewer';
+import {
+  mockMainBranch,
+  mockIssue,
+  mockSourceViewerFile,
+  mockFlowLocation,
+  mockSnippetsByComponent
+} from '../../../../helpers/testMocks';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../api/components', () => {
+  const { mockSnippetsByComponent } = require.requireActual('../../../../helpers/testMocks');
+
+  return {
+    getSources: jest
+      .fn()
+      .mockResolvedValue(
+        Object.values(
+          mockSnippetsByComponent('a', [22, 23, 24, 25, 26, 27, 28, 29, 30, 31]).sources
+        )
+      )
+  };
+});
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should expand block', async () => {
+  const snippetGroup: T.SnippetGroup = {
+    locations: [
+      mockFlowLocation({
+        component: 'a',
+        textRange: { startLine: 34, endLine: 34, startOffset: 0, endOffset: 0 }
+      }),
+      mockFlowLocation({
+        component: 'a',
+        textRange: { startLine: 54, endLine: 54, startOffset: 0, endOffset: 0 }
+      })
+    ],
+    ...mockSnippetsByComponent('a', [32, 33, 34, 35, 36, 52, 53, 54, 55, 56])
+  };
+
+  const wrapper = shallowRender({ snippetGroup });
+
+  wrapper.instance().expandBlock(0, 'up');
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state('snippets')).toHaveLength(2);
+  expect(wrapper.state('snippets')[0]).toHaveLength(15);
+  expect(Object.keys(wrapper.state('additionalLines'))).toHaveLength(10);
+});
+
+function shallowRender(props: Partial<ComponentSourceSnippetViewer['props']> = {}) {
+  const snippetGroup: T.SnippetGroup = {
+    component: mockSourceViewerFile(),
+    locations: [],
+    sources: []
+  };
+  return shallow<ComponentSourceSnippetViewer>(
+    <ComponentSourceSnippetViewer
+      branchLike={mockMainBranch()}
+      highlightedLocationMessage={{ index: 0, text: '' }}
+      issue={mockIssue()}
+      issuesByLine={{}}
+      last={false}
+      locations={[]}
+      onIssueChange={jest.fn()}
+      onIssuePopupToggle={jest.fn()}
+      onLocationSelect={jest.fn()}
+      renderDuplicationPopup={jest.fn()}
+      scroll={jest.fn()}
+      snippetGroup={snippetGroup}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx
new file mode 100644 (file)
index 0000000..63051e1
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import CrossComponentSourceViewerWrapper from '../CrossComponentSourceViewerWrapper';
+import { mockIssue, mockSourceViewerFile } from '../../../../helpers/testMocks';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../api/issues', () => {
+  const { mockSourceViewerFile } = require.requireActual('../../../../helpers/testMocks');
+  return {
+    getIssueFlowSnippets: jest.fn().mockResolvedValue([mockSourceViewerFile()])
+  };
+});
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot();
+});
+
+it('Should fetch data', async () => {
+  const wrapper = shallowRender();
+  wrapper.instance().fetchIssueFlowSnippets('124');
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state('components')).toEqual([mockSourceViewerFile()]);
+});
+
+it('should handle issue popup', () => {
+  const wrapper = shallowRender();
+  // open
+  wrapper.instance().handleIssuePopupToggle('1', 'popup1');
+  expect(wrapper.state('issuePopup')).toEqual({ issue: '1', name: 'popup1' });
+
+  // close
+  wrapper.instance().handleIssuePopupToggle('1', 'popup1');
+  expect(wrapper.state('issuePopup')).toBeUndefined();
+});
+
+function shallowRender(props: Partial<CrossComponentSourceViewerWrapper['props']> = {}) {
+  return shallow<CrossComponentSourceViewerWrapper>(
+    <CrossComponentSourceViewerWrapper
+      branchLike={undefined}
+      highlightedLocationMessage={undefined}
+      issue={mockIssue(true)}
+      issues={[]}
+      locations={[]}
+      onIssueChange={jest.fn()}
+      onLoaded={jest.fn()}
+      onLocationSelect={jest.fn()}
+      renderDuplicationPopup={jest.fn()}
+      scroll={jest.fn()}
+      selectedFlowIndex={0}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/ComponentSourceSnippetViewer-test.tsx.snap
new file mode 100644 (file)
index 0000000..136d07a
--- /dev/null
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+  className="component-source-container"
+>
+  <SourceViewerHeaderSlim
+    branchLike={
+      Object {
+        "analysisDate": "2018-01-01",
+        "isMain": true,
+        "name": "master",
+      }
+    }
+    expandable={true}
+    loading={false}
+    onExpand={[Function]}
+    sourceViewerFile={
+      Object {
+        "key": "foo",
+        "measures": Object {
+          "coverage": "85.2",
+          "duplicationDensity": "1.0",
+          "issues": "12",
+          "lines": "56",
+        },
+        "path": "foo/bar.ts",
+        "project": "my-project",
+        "projectName": "MyProject",
+        "q": "FIL",
+        "uuid": "foo-bar",
+      }
+    }
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap
new file mode 100644 (file)
index 0000000..9283b3f
--- /dev/null
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div>
+  <DeferredSpinner
+    timeout={100}
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts
new file mode 100644 (file)
index 0000000..ad114d7
--- /dev/null
@@ -0,0 +1,193 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { keyBy, range } from 'lodash';
+import { groupLocationsByComponent, createSnippets, expandSnippet } from '../utils';
+import {
+  mockFlowLocation,
+  mockSnippetsByComponent,
+  mockSourceLine
+} from '../../../../helpers/testMocks';
+
+describe('groupLocationsByComponent', () => {
+  it('should handle empty args', () => {
+    expect(groupLocationsByComponent([], {})).toEqual([]);
+  });
+
+  it('should group correctly', () => {
+    const results = groupLocationsByComponent(
+      [
+        mockFlowLocation({
+          textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 16, startOffset: 2, endLine: 16, endOffset: 3 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 24, startOffset: 1, endLine: 24, endOffset: 2 }
+        })
+      ],
+      { 'main.js': mockSnippetsByComponent('main.js', [14, 15, 16, 17, 18, 22, 23, 24, 25, 26]) }
+    );
+
+    expect(results).toHaveLength(1);
+  });
+
+  it('should preserve step order when jumping between files', () => {
+    const results = groupLocationsByComponent(
+      [
+        mockFlowLocation({
+          component: 'A.js',
+          textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 }
+        }),
+        mockFlowLocation({
+          component: 'B.js',
+          textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 }
+        }),
+        mockFlowLocation({
+          component: 'A.js',
+          textRange: { startLine: 15, startOffset: 2, endLine: 15, endOffset: 3 }
+        })
+      ],
+      {
+        'A.js': mockSnippetsByComponent('A.js', [13, 14, 15, 16, 17, 18]),
+        'B.js': mockSnippetsByComponent('B.js', [14, 15, 16, 17, 18])
+      }
+    );
+
+    expect(results).toHaveLength(3);
+    expect(results[0].component.key).toBe('A.js');
+    expect(results[1].component.key).toBe('B.js');
+    expect(results[2].component.key).toBe('A.js');
+    expect(results[0].locations).toHaveLength(1);
+    expect(results[1].locations).toHaveLength(1);
+    expect(results[2].locations).toHaveLength(1);
+  });
+});
+
+describe('createSnippets', () => {
+  it('should merge snippets correctly', () => {
+    const results = createSnippets(
+      [
+        mockFlowLocation({
+          textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 19, startOffset: 2, endLine: 19, endOffset: 3 }
+        })
+      ],
+      mockSnippetsByComponent('', [14, 15, 16, 17, 18, 19, 20, 21, 22]).sources,
+      false
+    );
+
+    expect(results).toHaveLength(1);
+    expect(results[0]).toHaveLength(8);
+  });
+
+  it('should merge snippets correctly, even when not in sequence', () => {
+    const results = createSnippets(
+      [
+        mockFlowLocation({
+          textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 47, startOffset: 2, endLine: 47, endOffset: 3 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 14, startOffset: 2, endLine: 14, endOffset: 3 }
+        })
+      ],
+      mockSnippetsByComponent('', [12, 13, 14, 15, 16, 17, 18, 45, 46, 47, 48, 49]).sources,
+      false
+    );
+
+    expect(results).toHaveLength(2);
+    expect(results[0]).toHaveLength(7);
+    expect(results[1]).toHaveLength(5);
+  });
+
+  it('should merge three snippets together', () => {
+    const results = createSnippets(
+      [
+        mockFlowLocation({
+          textRange: { startLine: 16, startOffset: 10, endLine: 16, endOffset: 14 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 47, startOffset: 2, endLine: 47, endOffset: 3 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 22, startOffset: 2, endLine: 22, endOffset: 3 }
+        }),
+        mockFlowLocation({
+          textRange: { startLine: 18, startOffset: 2, endLine: 18, endOffset: 3 }
+        })
+      ],
+      mockSnippetsByComponent('', [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 45, 46, 47, 48, 49])
+        .sources,
+      false
+    );
+
+    expect(results).toHaveLength(2);
+    expect(results[0]).toHaveLength(11);
+    expect(results[1]).toHaveLength(5);
+  });
+});
+
+describe('expandSnippet', () => {
+  it('should add lines above', () => {
+    const lines = keyBy(range(4, 19).map(line => mockSourceLine({ line })), 'line');
+    const snippets = [[lines[14], lines[15], lines[16], lines[17], lines[18]]];
+
+    const result = expandSnippet({ direction: 'up', lines, snippetIndex: 0, snippets });
+
+    expect(result).toHaveLength(1);
+    expect(result[0]).toHaveLength(15);
+    expect(result[0].map(l => l.line)).toEqual(range(4, 19));
+  });
+
+  it('should add lines below', () => {
+    const lines = keyBy(range(4, 19).map(line => mockSourceLine({ line })), 'line');
+    const snippets = [[lines[4], lines[5], lines[6], lines[7], lines[8]]];
+
+    const result = expandSnippet({ direction: 'down', lines, snippetIndex: 0, snippets });
+
+    expect(result).toHaveLength(1);
+    expect(result[0].map(l => l.line)).toEqual(range(4, 19));
+  });
+
+  it('should merge snippets if necessary', () => {
+    const lines = keyBy(
+      range(4, 23)
+        .concat(range(38, 43))
+        .map(line => mockSourceLine({ line })),
+      'line'
+    );
+    const snippets = [
+      [lines[4], lines[5], lines[6], lines[7], lines[8]],
+      [lines[38], lines[39], lines[40], lines[41], lines[42]],
+      [lines[17], lines[18], lines[19], lines[20], lines[21]]
+    ];
+
+    const result = expandSnippet({ direction: 'down', lines, snippetIndex: 0, snippets });
+
+    expect(result).toHaveLength(2);
+    expect(result[0].map(l => l.line)).toEqual(range(4, 22));
+    expect(result[1].map(l => l.line)).toEqual(range(38, 43));
+  });
+});
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
new file mode 100644 (file)
index 0000000..5c34f42
--- /dev/null
@@ -0,0 +1,185 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+const LINES_ABOVE = 2;
+const LINES_BELOW = 2;
+export const MERGE_DISTANCE = 4; // Merge if snippets are four lines away (separated by 3 lines) or fewer
+export const LINES_BELOW_LAST = 9;
+export const EXPAND_BY_LINES = 10;
+
+function unknownComponent(key: string): T.SnippetsByComponent {
+  return {
+    component: {
+      key,
+      measures: {},
+      path: '',
+      project: '',
+      projectName: '',
+      q: 'FIL',
+      uuid: ''
+    },
+    sources: []
+  };
+}
+
+function collision([startA, endA]: number[], [startB, endB]: number[]) {
+  return !(startA > endB + MERGE_DISTANCE || endA < startB - MERGE_DISTANCE);
+}
+
+export function createSnippets(
+  locations: T.FlowLocation[],
+  componentLines: T.LineMap = {},
+  last: boolean
+): T.SourceLine[][] {
+  return rangesToSnippets(
+    // For each location's range (2 above and 2 below), and then compare with other ranges
+    // to merge snippets that collide.
+    locations.reduce((snippets: Array<{ start: number; end: number }>, loc, index) => {
+      const startIndex = Math.max(1, loc.textRange.startLine - LINES_ABOVE);
+      const endIndex =
+        loc.textRange.endLine +
+        (last && index === locations.length - 1 ? LINES_BELOW_LAST : LINES_BELOW);
+
+      let firstCollision: { start: number; end: number } | undefined;
+
+      // Remove ranges that collide into the first collision
+      snippets = snippets.filter(snippet => {
+        if (collision([snippet.start, snippet.end], [startIndex, endIndex])) {
+          let keep = false;
+          // Check if we've already collided
+          if (!firstCollision) {
+            firstCollision = snippet;
+            keep = true;
+          }
+          // Merge with first collision:
+          firstCollision.start = Math.min(startIndex, snippet.start, firstCollision.start);
+          firstCollision.end = Math.max(endIndex, snippet.end, firstCollision.end);
+
+          // remove the range if it was not the first collision
+          return keep;
+        }
+        return true;
+      });
+
+      if (firstCollision === undefined) {
+        snippets.push({
+          start: startIndex,
+          end: endIndex
+        });
+      }
+
+      return snippets;
+    }, []),
+    componentLines
+  );
+}
+
+function rangesToSnippets(
+  ranges: Array<{ start: number; end: number }>,
+  componentLines: T.LineMap
+) {
+  return ranges
+    .map(range => {
+      const lines = [];
+      for (let i = range.start; i <= range.end; i++) {
+        if (componentLines[i]) {
+          lines.push(componentLines[i]);
+        }
+      }
+      return lines;
+    })
+    .filter(snippet => snippet.length > 0);
+}
+
+export function groupLocationsByComponent(
+  locations: T.FlowLocation[],
+  components: { [key: string]: T.SnippetsByComponent }
+) {
+  let currentComponent = '';
+  let currentGroup: T.SnippetGroup;
+  const groups: T.SnippetGroup[] = [];
+
+  locations.forEach((loc, index) => {
+    if (loc.component !== currentComponent) {
+      currentGroup = {
+        ...(components[loc.component] || unknownComponent(loc.component)),
+        locations: []
+      };
+      groups.push(currentGroup);
+      currentComponent = loc.component;
+    }
+    loc.index = index;
+    currentGroup.locations.push(loc);
+  });
+
+  return groups;
+}
+
+export function expandSnippet({
+  direction,
+  lines,
+  snippetIndex,
+  snippets
+}: {
+  direction: T.ExpandDirection;
+  lines: T.LineMap;
+  snippetIndex: number;
+  snippets: T.SourceLine[][];
+}) {
+  const snippetToExpand = snippets[snippetIndex];
+
+  const snippetToExpandRange = {
+    start: Math.max(0, snippetToExpand[0].line - (direction === 'up' ? EXPAND_BY_LINES : 0)),
+    end:
+      snippetToExpand[snippetToExpand.length - 1].line +
+      (direction === 'down' ? EXPAND_BY_LINES : 0)
+  };
+
+  const ranges: Array<{ start: number; end: number }> = [];
+
+  snippets.forEach((snippet, index: number) => {
+    const snippetRange = {
+      start: snippet[0].line,
+      end: snippet[snippet.length - 1].line
+    };
+
+    if (index === snippetIndex) {
+      // keep expanded snippet
+      ranges.push(snippetToExpandRange);
+    } else if (
+      collision(
+        [snippetRange.start, snippetRange.end],
+        [snippetToExpandRange.start, snippetToExpandRange.end]
+      )
+    ) {
+      // Merge with expanded snippet
+      snippetToExpandRange.start = Math.min(snippetRange.start, snippetToExpandRange.start);
+      snippetToExpandRange.end = Math.max(snippetRange.end, snippetToExpandRange.end);
+    } else {
+      // No collision, jsut keep the snippet
+      ranges.push(snippetRange);
+    }
+  });
+
+  return rangesToSnippets(ranges, lines);
+}
+
+export function inSnippet(line: number, snippet: T.SourceLine[]) {
+  return line >= snippet[0].line && line <= snippet[snippet.length - 1].line;
+}
index 1f2dba6d6a5cb309c225e6b8a8020eaebe81231c..1cb4a7e4973b8926743f22b8207cfe6ffdfcce00 100644 (file)
   border-color: rgba(209, 133, 130, 0.6);
 }
 
+.component-source-container {
+  border: 1px solid var(--gray80);
+}
+
+.component-source-container + .component-source-container {
+  margin-top: var(--gridSize);
+}
+
+.component-source-container-header {
+  background-color: var(--gray94);
+  padding: var(--gridSize);
+}
+
+.snippet {
+  margin: var(--gridSize);
+  border: 1px solid var(--gray80);
+  overflow-x: auto;
+}
+
+.snippet > .expand-block {
+  box-sizing: border-box;
+  color: var(--secondFontColor);
+  height: 20px;
+  width: 100%;
+  padding: calc(var(--gridSize) / 4);
+  border: 0;
+  text-align: left;
+  cursor: pointer;
+}
+.snippet > .expand-block:hover {
+  color: var(--darkBlue);
+}
+.snippet > .expand-block-above {
+  background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII=');
+}
+.snippet > .expand-block-below {
+  background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wQQBjQEQVd5jwAAADhJREFUCNddyTEKADEMA8GVA/7/Z+PGwUp1cGTaYe/tv5lxrLWoKj6SiMzkjZDEG7JtANt0N+ccLrB/KZxXTt7fAAAAAElFTkSuQmCC');
+}
+
 .issues-my-issues-filter {
   margin-bottom: 24px;
   text-align: center;
index 50164b72dc4722afa336e57235cc757fb522498b..10341ecd3b1f2919a44d83b9b740a9b104bfc945 100644 (file)
@@ -20,8 +20,9 @@
 import * as React from 'react';
 import * as classNames from 'classnames';
 import { intersection, uniqBy } from 'lodash';
-import SourceViewerHeader from './SourceViewerHeader';
 import SourceViewerCode from './SourceViewerCode';
+import SourceViewerHeader from './SourceViewerHeader';
+import SourceViewerHeaderSlim from './SourceViewerHeaderSlim';
 import { SourceViewerContext } from './SourceViewerContext';
 import DuplicationPopup from './components/DuplicationPopup';
 import defaultLoadIssues from './helpers/loadIssues';
@@ -81,6 +82,7 @@ export interface Props {
   scroll?: (element: HTMLElement) => void;
   selectedIssue?: string;
   showMeasures?: boolean;
+  slimHeader?: boolean;
 }
 
 interface State {
@@ -667,6 +669,24 @@ export default class SourceViewerBase extends React.PureComponent<Props, State>
     );
   }
 
+  renderHeader(branchLike: T.BranchLike | undefined, sourceViewerFile: T.SourceViewerFile) {
+    return this.props.slimHeader ? (
+      <SourceViewerHeaderSlim branchLike={branchLike} sourceViewerFile={sourceViewerFile} />
+    ) : (
+      <WorkspaceContext.Consumer>
+        {({ openComponent }) => (
+          <SourceViewerHeader
+            branchLike={this.props.branchLike}
+            issues={this.state.issues}
+            openComponent={openComponent}
+            showMeasures={this.props.showMeasures}
+            sourceViewerFile={sourceViewerFile}
+          />
+        )}
+      </WorkspaceContext.Consumer>
+    );
+  }
+
   render() {
     const { component, loading, sources, notAccessible, sourceRemoved } = this.state;
 
@@ -701,17 +721,7 @@ export default class SourceViewerBase extends React.PureComponent<Props, State>
     return (
       <SourceViewerContext.Provider value={{ branchLike: this.props.branchLike, file: component }}>
         <div className={className} ref={node => (this.node = node)}>
-          <WorkspaceContext.Consumer>
-            {({ openComponent }) => (
-              <SourceViewerHeader
-                branchLike={this.props.branchLike}
-                issues={this.state.issues}
-                openComponent={openComponent}
-                showMeasures={this.props.showMeasures}
-                sourceViewerFile={component}
-              />
-            )}
-          </WorkspaceContext.Consumer>
+          {this.renderHeader(this.props.branchLike, component)}
           {sourceRemoved && (
             <Alert className="spacer-top" variant="warning">
               {translate('code_viewer.no_source_code_displayed_due_to_source_removed')}
index cac8ee2dec8d088bb56c5878ffe7b9cb4ce4e0e4..04ecb86b671c5f1935fd11f1112c29010f018858 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { intersection } from 'lodash';
 import Line from './components/Line';
-import { getLinearLocations } from './helpers/issueLocations';
+import { getSecondaryIssueLocationsForLine } from './helpers/issueLocations';
+import {
+  optimizeSelectedIssue,
+  optimizeLocationMessage,
+  optimizeHighlightedSymbols
+} from './helpers/lines';
 import { translate } from '../../helpers/l10n';
 import { Button } from '../ui/buttons';
 
@@ -88,21 +92,6 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
     return this.props.issueLocationsByLine[line.line] || EMPTY_ARRAY;
   };
 
-  getSecondaryIssueLocationsForLine = (line: T.SourceLine): T.LinearIssueLocation[] => {
-    const { highlightedLocations } = this.props;
-    if (!highlightedLocations) {
-      return EMPTY_ARRAY;
-    }
-    return highlightedLocations.reduce((locations, location, index) => {
-      const linearLocations: T.LinearIssueLocation[] = location
-        ? getLinearLocations(location.textRange)
-            .filter(l => l.line === line.line)
-            .map(l => ({ ...l, startLine: location.textRange.startLine, index }))
-        : [];
-      return [...locations, ...linearLocations];
-    }, []);
-  };
-
   renderLine = ({
     line,
     index,
@@ -116,41 +105,14 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
     displayDuplications: boolean;
     displayIssues: boolean;
   }) => {
-    const { highlightedLocationMessage, selectedIssue, sources } = this.props;
+    const { highlightedLocationMessage, highlightedLocations, selectedIssue, sources } = this.props;
 
-    const secondaryIssueLocations = this.getSecondaryIssueLocationsForLine(line);
+    const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, highlightedLocations);
 
     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 { highlightedSymbols } = this.props;
-    let optimizedHighlightedSymbols: string[] | undefined = intersection(
-      symbolsForLine,
-      highlightedSymbols
-    );
-    if (!optimizedHighlightedSymbols.length) {
-      optimizedHighlightedSymbols = undefined;
-    }
-
-    const optimizedSelectedIssue =
-      selectedIssue !== undefined && issuesForLine.find(issue => issue.key === selectedIssue)
-        ? selectedIssue
-        : undefined;
-
-    const optimizedSecondaryIssueLocations =
-      secondaryIssueLocations.length > 0 ? secondaryIssueLocations : EMPTY_ARRAY;
-
-    const optimizedLocationMessage =
-      highlightedLocationMessage != null &&
-      optimizedSecondaryIssueLocations.some(
-        location => location.index === highlightedLocationMessage.index
-      )
-        ? highlightedLocationMessage
-        : undefined;
-
     return (
       <Line
         branchLike={this.props.branchLike}
@@ -162,8 +124,14 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
         duplications={this.getDuplicationsForLine(line)}
         duplicationsCount={duplicationsCount}
         highlighted={line.line === this.props.highlightedLine}
-        highlightedLocationMessage={optimizedLocationMessage}
-        highlightedSymbols={optimizedHighlightedSymbols}
+        highlightedLocationMessage={optimizeLocationMessage(
+          highlightedLocationMessage,
+          secondaryIssueLocations
+        )}
+        highlightedSymbols={optimizeHighlightedSymbols(
+          this.props.symbolsByLine[line.line],
+          this.props.highlightedSymbols
+        )}
         issueLocations={this.getIssueLocationsForLine(line)}
         issuePopup={this.props.issuePopup}
         issues={issuesForLine}
@@ -185,8 +153,8 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
         previousLine={index > 0 ? sources[index - 1] : undefined}
         renderDuplicationPopup={this.props.renderDuplicationPopup}
         scroll={this.props.scroll}
-        secondaryIssueLocations={optimizedSecondaryIssueLocations}
-        selectedIssue={optimizedSelectedIssue}
+        secondaryIssueLocations={secondaryIssueLocations}
+        selectedIssue={optimizeSelectedIssue(selectedIssue, issuesForLine)}
       />
     );
   };
index 4f367fdf5acee7c3e962899690db2aa1b8d839cf..c9f23440479a19667dcc4c35778961648cdcca56 100644 (file)
@@ -97,7 +97,7 @@ export default class SourceViewerHeader extends React.PureComponent<Props, State
               </a>
             </div>
 
-            {subProject != null && (
+            {subProject !== undefined && (
               <div className="component-name-parent">
                 <QualifierIcon qualifier="BRC" /> <span>{subProjectName}</span>
               </div>
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.css
new file mode 100644 (file)
index 0000000..eaaa3d7
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+.source-viewer-header-slim {
+  padding: 4px 10px 4px;
+  border-bottom: 1px solid var(--gray80);
+  background-color: var(--barBackgroundColor);
+  align-items: center;
+  min-height: 25px;
+}
+
+.source-viewer-header-slim-actions {
+  margin-left: calc(3 * var(--gridSize));
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerHeaderSlim.tsx
new file mode 100644 (file)
index 0000000..167472a
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import DeferredSpinner from '../common/DeferredSpinner';
+import Favorite from '../controls/Favorite';
+import ExpandSnippetIcon from '../icons-components/ExpandSnippetIcon';
+import QualifierIcon from '../icons-components/QualifierIcon';
+import { ButtonIcon } from '../ui/buttons';
+import { getPathUrlAsString, getBranchLikeUrl } from '../../helpers/urls';
+import { collapsedDirFromPath, fileFromPath } from '../../helpers/path';
+import { isMainBranch } from '../../helpers/branches';
+import './SourceViewerHeaderSlim.css';
+
+interface Props {
+  branchLike: T.BranchLike | undefined;
+  expandable?: boolean;
+  loading?: boolean;
+  onExpand?: () => void;
+  sourceViewerFile: T.SourceViewerFile;
+}
+
+export default function SourceViewerHeaderSlim({
+  branchLike,
+  expandable,
+  loading,
+  onExpand,
+  sourceViewerFile
+}: Props) {
+  const { key, path, project, projectName, q, subProject, subProjectName } = sourceViewerFile;
+
+  return (
+    <div className="source-viewer-header-slim display-flex-row display-flex-space-between">
+      <div className="display-flex-row flex-1">
+        <div>
+          <a
+            className="link-with-icon"
+            href={getPathUrlAsString(getBranchLikeUrl(project, branchLike))}>
+            <QualifierIcon qualifier="TRK" /> <span>{projectName}</span>
+          </a>
+        </div>
+
+        {subProject !== undefined && (
+          <div className="">
+            <QualifierIcon qualifier="BRC" /> <span>{subProjectName}</span>
+          </div>
+        )}
+
+        <div className="spacer-left">
+          <QualifierIcon qualifier={q} /> <span>{collapsedDirFromPath(path)}</span>
+          <span className="component-name-file">{fileFromPath(path)}</span>
+        </div>
+        {sourceViewerFile.canMarkAsFavorite && (!branchLike || isMainBranch(branchLike)) && (
+          <div className="nudged-up">
+            <Favorite
+              className="component-name-favorite"
+              component={key}
+              favorite={sourceViewerFile.fav || false}
+              qualifier={sourceViewerFile.q}
+            />
+          </div>
+        )}
+      </div>
+
+      {expandable && (
+        <DeferredSpinner className="little-spacer-right" loading={loading}>
+          <div className="source-viewer-header-slim-actions flex-0">
+            <ButtonIcon className="js-actions" onClick={onExpand}>
+              <ExpandSnippetIcon />
+            </ButtonIcon>
+          </div>
+        </DeferredSpinner>
+      )}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.css b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.css
new file mode 100644 (file)
index 0000000..0c034c3
--- /dev/null
@@ -0,0 +1,257 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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.
+ */
+.source-line:hover .source-line-number,
+.source-line:hover .source-line-issues,
+.source-line:hover .source-line-coverage,
+.source-line:hover .source-line-duplications,
+.source-line:hover .source-line-duplications-extra,
+.source-line:hover .source-line-scm {
+  border-color: #e9e9e9;
+  background-color: #e9e9e9;
+}
+
+.source-line:hover .source-line-code {
+  background-color: #f5f5f5;
+}
+
+.source-line-highlighted .source-line-number,
+.source-line-highlighted:hover .source-line-number,
+.source-line-highlighted .source-line-issues,
+.source-line-highlighted:hover .source-line-issues,
+.source-line-highlighted .source-line-coverage,
+.source-line-highlighted:hover .source-line-coverage,
+.source-line-highlighted .source-line-duplications,
+.source-line-highlighted:hover .source-line-duplications,
+.source-line-highlighted .source-line-duplications-extra,
+.source-line-highlighted:hover .source-line-duplications-extra,
+.source-line-highlighted .source-line-scm,
+.source-line-highlighted:hover .source-line-scm {
+  border-color: #c4dfec !important;
+  background-color: #c4dfec;
+}
+
+.source-line-highlighted .source-line-code,
+.source-line-highlighted:hover .source-line-code {
+  background-color: #d9edf7;
+}
+
+.source-line-filtered .source-line-code {
+  background-color: var(--leakColor) !important;
+}
+
+.source-line-filtered.source-line-highlighted .source-line-code,
+.source-line-filtered.source-line-highlighted:hover .source-line-code {
+  background-color: #cdd9c4 !important;
+}
+
+.source-line-filtered:hover .source-line-code {
+  background-color: #f1e8cb !important;
+}
+
+.source-line-filtered.source-line-filtered-dark .source-line-code {
+  background-color: #f9ebb7 !important;
+}
+
+.source-line-filtered.source-line-filtered-dark:hover .source-line-code {
+  background-color: #eaddb2 !important;
+}
+
+.source-line-last .source-line-code {
+  padding-bottom: 160px;
+}
+
+.source-viewer pre {
+  height: 18px;
+  padding: 0;
+}
+
+.source-viewer pre,
+.source-line-number,
+.source-line-scm {
+  line-height: 18px;
+  font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+  font-size: var(--smallFontSize);
+}
+
+.source-line-code {
+  position: relative;
+  padding: 0 10px;
+}
+
+.source-line-code pre {
+  float: left;
+}
+
+.source-line-code .issue-list {
+  margin-left: -10px;
+  margin-right: -10px;
+}
+
+.source-line-code-inner {
+  min-height: 18px;
+}
+
+.source-line-code-inner:before,
+.source-line-code-inner:after {
+  display: table;
+  content: '';
+  line-height: 0;
+}
+
+.source-line-code-inner:after {
+  clear: both;
+}
+
+.source-line-code-issue {
+  display: inline-block;
+  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAGCAYAAAAPDoR2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo1M0M2Rjk4M0M3QUYxMUUzODkzRUREMUM5OTNDMjY4QSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo1M0M2Rjk4NEM3QUYxMUUzODkzRUREMUM5OTNDMjY4QSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjUzQzZGOTgxQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjUzQzZGOTgyQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+bcqJtQAAAEhJREFUeNpi+G+swwDGDAwgbAWlwZiJAQFCgfgwEIfDRaC67ID4NRDnQ2kQnwFZwgFqnANMAQOUYY9sF0wBiCGH5CBkrAgQYACuWi4sSGW8yAAAAABJRU5ErkJggg==);
+  background-repeat: repeat-x;
+  background-size: 4px;
+  background-position: bottom;
+}
+
+.source-meta {
+  position: relative;
+  vertical-align: top;
+  width: 1px;
+  background-clip: padding-box;
+  user-select: none;
+}
+
+.source-meta:focus {
+  outline: none;
+}
+
+.source-meta[role='button'] {
+  cursor: pointer;
+}
+
+.source-meta + .source-meta {
+  border-left: 1px solid var(--barBackgroundColor);
+}
+
+.source-line-number {
+  min-width: 18px;
+  padding: 0 10px;
+  background-color: var(--barBackgroundColor);
+  color: var(--secondFontColor);
+  text-align: right;
+}
+
+.source-line-number:before {
+  content: attr(data-line-number);
+}
+
+.source-line-issues {
+  position: relative;
+  padding: 0 2px;
+  background-color: var(--barBackgroundColor);
+  white-space: nowrap;
+}
+
+.source-line-with-issues {
+  padding-right: 4px;
+}
+
+.source-line-issues-counter {
+  position: absolute;
+  left: 17px;
+  line-height: 8px;
+  font-size: 8px;
+  z-index: 900;
+}
+
+.source-line-coverage {
+  background-color: var(--barBackgroundColor);
+}
+
+.source-line-duplications,
+.source-line-duplications-extra {
+  background-color: var(--barBackgroundColor);
+}
+
+.source-line-duplications-extra {
+  display: none;
+}
+
+.source-duplications-expanded .source-line-duplications {
+  display: none;
+}
+
+.source-duplications-expanded .source-line-duplications-extra {
+  display: table-cell;
+}
+
+.source-line-scm {
+  padding: 0 5px;
+  background-color: var(--barBackgroundColor);
+}
+
+.source-line-scm-inner {
+  max-width: 40px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.source-line-scm-inner:before {
+  content: attr(data-author);
+}
+
+.source-line-bar {
+  width: 5px;
+  height: 18px;
+}
+
+.source-line-bar[role='button'] {
+  cursor: pointer;
+}
+
+.source-line-bar:focus {
+  outline: none;
+}
+
+.source-line-covered {
+  background-color: var(--lineCoverageGreen) !important;
+}
+
+.source-line-uncovered {
+  background-color: var(--lineCoverageRed) !important;
+}
+
+.source-line-partially-covered {
+  background-color: var(--lineCoverageRed) !important;
+  background-image: repeating-linear-gradient(
+    45deg,
+    rgba(255, 255, 255, 0.5) 4px,
+    transparent 4px,
+    transparent 8px,
+    rgba(255, 255, 255, 0.5) 8px,
+    rgba(255, 255, 255, 0.5) 12px,
+    transparent 12px,
+    transparent 16px,
+    rgba(255, 255, 255, 0.5) 16px,
+    rgba(255, 255, 255, 0.5) 20px
+  ) !important;
+}
+
+.source-line-duplicated {
+  background-color: #797979 !important;
+}
index 5d26fe77b9bdaabd04693d41c6933d41966e171d..79e938a542cc4fd63df61bb36f93d9953e02fdce 100644 (file)
@@ -27,6 +27,7 @@ import LineDuplications from './LineDuplications';
 import LineDuplicationBlock from './LineDuplicationBlock';
 import LineIssuesIndicator from './LineIssuesIndicator';
 import LineCode from './LineCode';
+import './Line.css';
 
 interface Props {
   branchLike: T.BranchLike | undefined;
@@ -60,16 +61,13 @@ interface Props {
   previousLine: T.SourceLine | undefined;
   renderDuplicationPopup: (index: number, line: number) => JSX.Element;
   scroll?: (element: HTMLElement) => void;
-  secondaryIssueLocations: Array<{
-    from: number;
-    to: number;
-    line: number;
-    index: number;
-    startLine: number;
-  }>;
+  secondaryIssueLocations: T.LinearIssueLocation[];
   selectedIssue: string | undefined;
+  verticalBuffer?: number;
 }
 
+const LINE_HEIGHT = 18;
+
 export default class Line extends React.PureComponent<Props> {
   isPopupOpen = (name: string, index?: number) => {
     const { line, linePopup } = this.props;
@@ -103,9 +101,13 @@ export default class Line extends React.PureComponent<Props> {
       'source-line-filtered-dark':
         displayCoverage &&
         (line.coverageStatus === 'uncovered' || line.coverageStatus === 'partially-covered'),
-      'source-line-last': this.props.last
+      'source-line-last': this.props.last === true
     });
 
+    const bottomPadding = this.props.verticalBuffer
+      ? this.props.verticalBuffer * LINE_HEIGHT
+      : undefined;
+
     return (
       <tr className={className} data-line-number={line.line}>
         <LineNumber
@@ -121,12 +123,14 @@ export default class Line extends React.PureComponent<Props> {
           previousLine={this.props.previousLine}
         />
 
-        {this.props.displayIssues && !this.props.displayAllIssues && (
+        {this.props.displayIssues && !this.props.displayAllIssues ? (
           <LineIssuesIndicator
             issues={this.props.issues}
             line={line}
             onClick={this.handleIssuesIndicatorClick}
           />
+        ) : (
+          <td className="source-meta source-line-issues" />
         )}
 
         {this.props.displayDuplications && (
@@ -161,6 +165,7 @@ export default class Line extends React.PureComponent<Props> {
           onIssueSelect={this.props.onIssueSelect}
           onLocationSelect={this.props.onLocationSelect}
           onSymbolClick={this.props.onSymbolClick}
+          padding={bottomPadding}
           scroll={this.props.scroll}
           secondaryIssueLocations={this.props.secondaryIssueLocations}
           selectedIssue={this.props.selectedIssue}
index 7b9bd25fba93339f267eea09646a89cec5dfc2e9..485353c962627708c19663f76937e39003ce7593 100644 (file)
@@ -43,14 +43,9 @@ interface Props {
   onIssueSelect: (issueKey: string) => void;
   onLocationSelect: ((index: number) => void) | undefined;
   onSymbolClick: (symbols: Array<string>) => void;
+  padding?: number;
   scroll?: (element: HTMLElement) => void;
-  secondaryIssueLocations: Array<{
-    from: number;
-    to: number;
-    line: number;
-    index: number;
-    startLine: number;
-  }>;
+  secondaryIssueLocations: T.LinearIssueLocation[];
   selectedIssue: string | undefined;
   showIssues?: boolean;
 }
@@ -94,7 +89,9 @@ export default class LineCode extends React.PureComponent<Props, State> {
     this.attachEvents();
     if (
       this.props.highlightedLocationMessage &&
-      prevProps.highlightedLocationMessage !== this.props.highlightedLocationMessage &&
+      (!prevProps.highlightedLocationMessage ||
+        prevProps.highlightedLocationMessage.index !==
+          this.props.highlightedLocationMessage.index) &&
       this.activeMarkerNode &&
       this.props.scroll
     ) {
@@ -159,6 +156,7 @@ export default class LineCode extends React.PureComponent<Props, State> {
       issueLocations,
       line,
       onIssueSelect,
+      padding,
       secondaryIssueLocations,
       selectedIssue,
       showIssues
@@ -204,7 +202,8 @@ export default class LineCode extends React.PureComponent<Props, State> {
         token.markers.forEach(marker => {
           const selected =
             highlightedLocationMessage !== undefined && highlightedLocationMessage.index === marker;
-          const message = selected ? highlightedLocationMessage!.text : undefined;
+          const loc = secondaryIssueLocations.find(loc => loc.index === marker);
+          const message = loc && loc.text;
           renderedTokens.push(this.renderMarker(marker, message, selected, leadingMarker));
         });
       }
@@ -218,8 +217,14 @@ export default class LineCode extends React.PureComponent<Props, State> {
       leadingMarker = (index === 0 ? true : leadingMarker) && !token.text.trim().length;
     });
 
+    const style = padding
+      ? {
+          paddingBottom: padding + 'px'
+        }
+      : undefined;
+
     return (
-      <td className={className} data-line-number={line.line}>
+      <td className={className} data-line-number={line.line} style={style}>
         <div className="source-line-code-inner">
           <pre ref={node => (this.codeNode = node)}>{renderedTokens}</pre>
         </div>
@@ -234,6 +239,17 @@ export default class LineCode extends React.PureComponent<Props, State> {
             selectedIssue={selectedIssue}
           />
         )}
+        {selectedIssue && !showIssues && (
+          <LineIssuesList
+            branchLike={this.props.branchLike}
+            issuePopup={this.props.issuePopup}
+            issues={issues.filter(i => i.key === selectedIssue)}
+            onIssueChange={this.props.onIssueChange}
+            onIssueClick={onIssueSelect}
+            onIssuePopupToggle={this.props.onIssuePopupToggle}
+            selectedIssue={selectedIssue}
+          />
+        )}
       </td>
     );
   }
index 0281bf0f86324d052a60d61f5313f2a02b6ac0f7..084cba155fad99991a1413757cf1f96331fe9344 100644 (file)
@@ -92,8 +92,6 @@ function shallowRender(props: Partial<Line['props']> = {}) {
       displayAllIssues={false}
       displayCoverage={false}
       displayDuplications={false}
-      displayIssueLocationsCount={false}
-      displayIssueLocationsLink={false}
       displayIssues={false}
       displayLocationMarkers={false}
       duplications={[]}
index cc374a63141fca687a7518e8aaa3ae3c28cedc37..fee4f4dd80001e9f1a67e7e36195def048e89095 100644 (file)
@@ -2,16 +2,21 @@
 
 exports[`should render correctly 1`] = `
 <tr
-  className="source-line"
-  data-line-number={5}
+  className="source-line source-line-filtered"
+  data-line-number={16}
 >
   <LineNumber
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -20,15 +25,23 @@ exports[`should render correctly 1`] = `
   <LineSCM
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
     popupOpen={false}
   />
+  <td
+    className="source-meta source-line-issues"
+  />
   <LineCode
     branchLike={
       Object {
@@ -40,8 +53,6 @@ exports[`should render correctly 1`] = `
         "title": "Foo Bar feature",
       }
     }
-    displayIssueLocationsCount={false}
-    displayIssueLocationsLink={false}
     displayLocationMarkers={false}
     issueLocations={Array []}
     issues={
@@ -112,10 +123,15 @@ exports[`should render correctly 1`] = `
     }
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onIssueChange={[MockFunction]}
@@ -133,16 +149,20 @@ exports[`should render correctly 1`] = `
 exports[`should render correctly for last, new, and highlighted lines 1`] = `
 <tr
   className="source-line source-line-highlighted source-line-filtered source-line-last"
-  data-line-number={5}
+  data-line-number={16}
 >
   <LineNumber
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
+        "duplicated": false,
         "isNew": true,
-        "line": 5,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -151,16 +171,23 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = `
   <LineSCM
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
+        "duplicated": false,
         "isNew": true,
-        "line": 5,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
     popupOpen={false}
   />
+  <td
+    className="source-meta source-line-issues"
+  />
   <LineCode
     branchLike={
       Object {
@@ -172,8 +199,6 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = `
         "title": "Foo Bar feature",
       }
     }
-    displayIssueLocationsCount={false}
-    displayIssueLocationsLink={false}
     displayLocationMarkers={false}
     issueLocations={Array []}
     issues={
@@ -244,11 +269,15 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = `
     }
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
+        "duplicated": false,
         "isNew": true,
-        "line": 5,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onIssueChange={[MockFunction]}
@@ -265,16 +294,21 @@ exports[`should render correctly for last, new, and highlighted lines 1`] = `
 
 exports[`should render correctly with coverage 1`] = `
 <tr
-  className="source-line"
-  data-line-number={5}
+  className="source-line source-line-filtered"
+  data-line-number={16}
 >
   <LineNumber
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -283,22 +317,35 @@ exports[`should render correctly with coverage 1`] = `
   <LineSCM
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
     popupOpen={false}
   />
+  <td
+    className="source-meta source-line-issues"
+  />
   <LineCoverage
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
   />
@@ -313,8 +360,6 @@ exports[`should render correctly with coverage 1`] = `
         "title": "Foo Bar feature",
       }
     }
-    displayIssueLocationsCount={false}
-    displayIssueLocationsLink={false}
     displayLocationMarkers={false}
     issueLocations={Array []}
     issues={
@@ -385,10 +430,15 @@ exports[`should render correctly with coverage 1`] = `
     }
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onIssueChange={[MockFunction]}
@@ -405,16 +455,21 @@ exports[`should render correctly with coverage 1`] = `
 
 exports[`should render correctly with duplication information 1`] = `
 <tr
-  className="source-line"
-  data-line-number={5}
+  className="source-line source-line-filtered"
+  data-line-number={16}
 >
   <LineNumber
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -423,22 +478,35 @@ exports[`should render correctly with duplication information 1`] = `
   <LineSCM
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
     popupOpen={false}
   />
+  <td
+    className="source-meta source-line-issues"
+  />
   <LineDuplications
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onClick={[MockFunction]}
@@ -449,10 +517,15 @@ exports[`should render correctly with duplication information 1`] = `
     key="0"
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -465,10 +538,15 @@ exports[`should render correctly with duplication information 1`] = `
     key="1"
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -481,10 +559,15 @@ exports[`should render correctly with duplication information 1`] = `
     key="2"
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -502,8 +585,6 @@ exports[`should render correctly with duplication information 1`] = `
         "title": "Foo Bar feature",
       }
     }
-    displayIssueLocationsCount={false}
-    displayIssueLocationsLink={false}
     displayLocationMarkers={false}
     issueLocations={Array []}
     issues={
@@ -574,10 +655,15 @@ exports[`should render correctly with duplication information 1`] = `
     }
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onIssueChange={[MockFunction]}
@@ -594,16 +680,21 @@ exports[`should render correctly with duplication information 1`] = `
 
 exports[`should render correctly with issues info 1`] = `
 <tr
-  className="source-line"
-  data-line-number={5}
+  className="source-line source-line-filtered"
+  data-line-number={16}
 >
   <LineNumber
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -612,10 +703,15 @@ exports[`should render correctly with issues info 1`] = `
   <LineSCM
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onPopupToggle={[MockFunction]}
@@ -690,10 +786,15 @@ exports[`should render correctly with issues info 1`] = `
     }
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onClick={[Function]}
@@ -709,8 +810,6 @@ exports[`should render correctly with issues info 1`] = `
         "title": "Foo Bar feature",
       }
     }
-    displayIssueLocationsCount={false}
-    displayIssueLocationsLink={false}
     displayLocationMarkers={false}
     issueLocations={Array []}
     issues={
@@ -781,10 +880,15 @@ exports[`should render correctly with issues info 1`] = `
     }
     line={
       Object {
-        "code": "function fooBar() {",
+        "code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
         "coverageStatus": "covered",
         "coveredConditions": 2,
-        "line": 5,
+        "duplicated": false,
+        "isNew": true,
+        "line": 16,
+        "scmAuthor": "simon.brandhof@sonarsource.com",
+        "scmDate": "2018-12-11T10:48:39+0100",
+        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
       }
     }
     onIssueChange={[MockFunction]}
index 23d0b0221ba20ec0a73dae0fc064e08b8f189bf2..12f156294bddb490e3f2b86fd96d5e5ef946f5c0 100644 (file)
@@ -33,6 +33,18 @@ export function issuesByLine(issues: T.Issue[]) {
   return index;
 }
 
+export function issuesByComponentAndLine(
+  issues: T.Issue[] = []
+): { [component: string]: { [line: number]: T.Issue[] } } {
+  return issues.reduce((mapping: { [component: string]: { [line: number]: T.Issue[] } }, issue) => {
+    mapping[issue.component] = mapping[issue.component] || {};
+    const line = issue.textRange ? issue.textRange.endLine : 0;
+    mapping[issue.component][line] = mapping[issue.component][line] || [];
+    mapping[issue.component][line].push(issue);
+    return mapping;
+  }, {});
+}
+
 export function locationsByLine(issues: T.Issue[]) {
   const index: { [line: number]: T.LinearIssueLocation[] } = {};
   issues.forEach(issue => {
index 67cfae3cbf6bcb57d36295f9a4888240f82785a3..9218ea9168ee7c577028940a828f4e488d0087b9 100644 (file)
@@ -32,3 +32,25 @@ export function getLinearLocations(textRange: T.TextRange | undefined): T.Linear
   }
   return locations;
 }
+
+export function getSecondaryIssueLocationsForLine(
+  line: T.SourceLine,
+  highlightedLocations: (T.FlowLocation | undefined)[] | undefined
+): T.LinearIssueLocation[] {
+  if (!highlightedLocations) {
+    return [];
+  }
+  return highlightedLocations.reduce((locations, location) => {
+    const linearLocations: T.LinearIssueLocation[] = location
+      ? getLinearLocations(location.textRange)
+          .filter(l => l.line === line.line)
+          .map(l => ({
+            ...l,
+            startLine: location.textRange.startLine,
+            index: location.index,
+            text: location.msg
+          }))
+      : [];
+    return [...locations, ...linearLocations];
+  }, []);
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts
new file mode 100644 (file)
index 0000000..b4de838
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 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 { intersection } from 'lodash';
+
+export function optimizeHighlightedSymbols(
+  symbolsForLine: string[] = [],
+  highlightedSymbols: string[] = []
+): string[] | undefined {
+  const symbols = intersection(symbolsForLine, highlightedSymbols);
+
+  return symbols.length ? symbols : undefined;
+}
+
+export function optimizeLocationMessage(
+  highlightedLocationMessage: { index: number; text: string | undefined } | undefined,
+  optimizedSecondaryIssueLocations: T.LinearIssueLocation[]
+) {
+  return highlightedLocationMessage != null &&
+    optimizedSecondaryIssueLocations.some(
+      location => location.index === highlightedLocationMessage.index
+    )
+    ? highlightedLocationMessage
+    : undefined;
+}
+
+export function optimizeSelectedIssue(selectedIssue: string | undefined, issuesForLine: T.Issue[]) {
+  return selectedIssue !== undefined && issuesForLine.find(issue => issue.key === selectedIssue)
+    ? selectedIssue
+    : undefined;
+}
index 84bd8a63a3ab26585aa6fbea65f63c37193239b5..db5f812a00b7804b1386a022d1eba815a33ba8cc 100644 (file)
   border-collapse: collapse;
 }
 
-.source-line:hover .source-line-number,
-.source-line:hover .source-line-issues,
-.source-line:hover .source-line-coverage,
-.source-line:hover .source-line-duplications,
-.source-line:hover .source-line-duplications-extra,
-.source-line:hover .source-line-scm {
-  border-color: #e9e9e9;
-  background-color: #e9e9e9;
-}
-
-.source-line:hover .source-line-code {
-  background-color: #f5f5f5;
-}
-
-.source-line-highlighted .source-line-number,
-.source-line-highlighted:hover .source-line-number,
-.source-line-highlighted .source-line-issues,
-.source-line-highlighted:hover .source-line-issues,
-.source-line-highlighted .source-line-coverage,
-.source-line-highlighted:hover .source-line-coverage,
-.source-line-highlighted .source-line-duplications,
-.source-line-highlighted:hover .source-line-duplications,
-.source-line-highlighted .source-line-duplications-extra,
-.source-line-highlighted:hover .source-line-duplications-extra,
-.source-line-highlighted .source-line-scm,
-.source-line-highlighted:hover .source-line-scm {
-  border-color: #c4dfec !important;
-  background-color: #c4dfec;
-}
-
-.source-line-highlighted .source-line-code,
-.source-line-highlighted:hover .source-line-code {
-  background-color: #d9edf7;
-}
-
-.source-line-filtered .source-line-code {
-  background-color: var(--leakColor) !important;
-}
-
-.source-line-filtered.source-line-highlighted .source-line-code,
-.source-line-filtered.source-line-highlighted:hover .source-line-code {
-  background-color: #cdd9c4 !important;
-}
-
-.source-line-filtered:hover .source-line-code {
-  background-color: #f1e8cb !important;
-}
-
-.source-line-filtered.source-line-filtered-dark .source-line-code {
-  background-color: #f9ebb7 !important;
-}
-
-.source-line-filtered.source-line-filtered-dark:hover .source-line-code {
-  background-color: #eaddb2 !important;
-}
-
-.source-line-last .source-line-code {
-  padding-bottom: 160px;
-}
-
-.source-viewer pre {
-  height: 18px;
-  padding: 0;
-}
-
-.source-viewer pre,
-.source-line-number,
-.source-line-scm {
-  line-height: 18px;
-  font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
-  font-size: var(--smallFontSize);
-}
-
-.source-line-code {
-  position: relative;
-  padding: 0 10px;
-}
-
-.source-line-code pre {
-  float: left;
-}
-
-.source-line-code .issue-list {
-  margin-left: -10px;
-  margin-right: -10px;
-}
-
-.source-line-code-inner:before,
-.source-line-code-inner:after {
-  display: table;
-  content: '';
-  line-height: 0;
-}
-
-.source-line-code-inner:after {
-  clear: both;
-}
-
-.source-line-code-issue {
-  display: inline-block;
-  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAGCAYAAAAPDoR2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyRpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo1M0M2Rjk4M0M3QUYxMUUzODkzRUREMUM5OTNDMjY4QSIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo1M0M2Rjk4NEM3QUYxMUUzODkzRUREMUM5OTNDMjY4QSI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjUzQzZGOTgxQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjUzQzZGOTgyQzdBRjExRTM4OTNFREQxQzk5M0MyNjhBIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+bcqJtQAAAEhJREFUeNpi+G+swwDGDAwgbAWlwZiJAQFCgfgwEIfDRaC67ID4NRDnQ2kQnwFZwgFqnANMAQOUYY9sF0wBiCGH5CBkrAgQYACuWi4sSGW8yAAAAABJRU5ErkJggg==);
-  background-repeat: repeat-x;
-  background-size: 4px;
-  background-position: bottom;
-}
-
-.source-meta {
-  position: relative;
-  vertical-align: top;
-  width: 1px;
-  background-clip: padding-box;
-  user-select: none;
-}
-
-.source-meta:focus {
-  outline: none;
-}
-
-.source-meta[role='button'] {
-  cursor: pointer;
-}
-
-.source-meta + .source-meta {
-  border-left: 1px solid var(--barBackgroundColor);
-}
-
-.source-line-number {
-  min-width: 18px;
-  padding: 0 10px;
-  background-color: var(--barBackgroundColor);
-  color: var(--secondFontColor);
-  text-align: right;
-}
-
-.source-line-number:before {
-  content: attr(data-line-number);
-}
-
-.source-line-issues {
-  position: relative;
-  padding: 0 2px;
-  background-color: var(--barBackgroundColor);
-  white-space: nowrap;
-}
-
-.source-line-with-issues {
-  padding-right: 4px;
-}
-
-.source-line-issues-counter {
-  position: absolute;
-  left: 17px;
-  line-height: 8px;
-  font-size: 8px;
-  z-index: 900;
-}
-
-.source-line-coverage {
-  background-color: var(--barBackgroundColor);
-}
-
-.source-line-duplications,
-.source-line-duplications-extra {
-  background-color: var(--barBackgroundColor);
-}
-
-.source-line-duplications-extra {
-  display: none;
-}
-
-.source-duplications-expanded .source-line-duplications {
-  display: none;
-}
-
-.source-duplications-expanded .source-line-duplications-extra {
-  display: table-cell;
-}
-
-.source-line-scm {
-  padding: 0 5px;
-  background-color: var(--barBackgroundColor);
-}
-
-.source-line-scm-inner {
-  max-width: 40px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.source-line-scm-inner:before {
-  content: attr(data-author);
-}
-
-.source-line-bar {
-  width: 5px;
-  height: 18px;
-}
-
-.source-line-bar[role='button'] {
-  cursor: pointer;
-}
-
-.source-line-bar:focus {
-  outline: none;
-}
-
-.source-line-covered {
-  background-color: var(--lineCoverageGreen) !important;
-}
-
-.source-line-uncovered {
-  background-color: var(--lineCoverageRed) !important;
-}
-
-.source-line-partially-covered {
-  background-color: var(--lineCoverageRed) !important;
-  background-image: repeating-linear-gradient(
-    45deg,
-    rgba(255, 255, 255, 0.5) 4px,
-    transparent 4px,
-    transparent 8px,
-    rgba(255, 255, 255, 0.5) 8px,
-    rgba(255, 255, 255, 0.5) 12px,
-    transparent 12px,
-    transparent 16px,
-    rgba(255, 255, 255, 0.5) 16px,
-    rgba(255, 255, 255, 0.5) 20px
-  ) !important;
-}
-
-.source-line-duplicated {
-  background-color: #797979 !important;
-}
-
 .source-viewer-header {
   position: relative;
   padding: 2px 10px 4px;
index fc4443ce34b10a01e1061da5164456732c31197b..9d1cf112bae52533784165b5b4193d89847ccddd 100644 (file)
@@ -34,7 +34,7 @@
 }
 
 .location-index.selected {
-  background-color: #bc5e5e;
+  background-color: #8f3030;
 }
 
 .location-index.muted {
index 29b666d2fdbcebf20d34b44905cecb0a26c82480..cd410c20491dddd01c2fabbb89fe13b6f46edecc 100644 (file)
 }
 
 .location-index > .location-message {
+  display: none;
   position: absolute;
   bottom: calc(100% + 4px);
   left: 0;
 }
 
+.location-index:hover > .location-message {
+  display: block;
+}
+
 .location-index > .location-message::after {
   position: absolute;
   bottom: -5px;
diff --git a/server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/ExpandSnippetIcon.tsx
new file mode 100644 (file)
index 0000000..e99d2ba
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import Icon, { IconProps } from './Icon';
+
+export default function ExpandSnippetIcon({ className, fill = 'currentColor', size }: IconProps) {
+  return (
+    <Icon className={className} size={size}>
+      <g fill="none" fillRule="evenodd">
+        <path
+          d="M8 1v4H4"
+          stroke={fill}
+          strokeWidth="2"
+          transform="scale(-.83333 -.84583) rotate(45 7.66 -19.75)"
+        />
+        <path d="M3 5.78h10v1.7H3z" fill={fill} />
+        <path d="M7.17 2.4h1.66v5.07H7.17z" fill={fill} />
+        <g>
+          <path
+            d="M8.16 1.81V6.1H3.9"
+            stroke={fill}
+            strokeWidth="2"
+            transform="scale(.83333 .84583) rotate(45 -4.2 13.2)"
+          />
+          <path d="M13 10.01H3v-1.7h10z" fill={fill} />
+          <path d="M8.83 13.4H7.17V9.15h1.66z" fill={fill} />
+        </g>
+      </g>
+    </Icon>
+  );
+}
index cc30abeb85e4853feda8e11642568cc4da98cdbd..9ec9ef6f2945f5c147572a1f8473e0425254e9e2 100644 (file)
@@ -155,6 +155,38 @@ export function mockQualityGateStatusCondition(
   };
 }
 
+export function mockSnippetsByComponent(
+  component = 'main.js',
+  lines: number[] = [16]
+): T.SnippetsByComponent {
+  const sources = lines.reduce((lines: { [key: number]: T.SourceLine }, line) => {
+    lines[line] = mockSourceLine({ line });
+    return lines;
+  }, {});
+  return {
+    component: mockSourceViewerFile({
+      key: component,
+      path: component
+    }),
+    sources
+  };
+}
+
+export function mockSourceLine(overrides: Partial<T.SourceLine> = {}): T.SourceLine {
+  return {
+    line: 16,
+    code: '<span class="k">import</span> java.util.<span class="sym-9 sym">ArrayList</span>;',
+    coverageStatus: 'covered',
+    coveredConditions: 2,
+    scmRevision: '80f564becc0c0a1c9abaa006eca83a4fd278c3f0',
+    scmAuthor: 'simon.brandhof@sonarsource.com',
+    scmDate: '2018-12-11T10:48:39+0100',
+    duplicated: false,
+    isNew: true,
+    ...overrides
+  };
+}
+
 export function mockCurrentUser(overrides: Partial<T.CurrentUser> = {}): T.CurrentUser {
   return {
     isLoggedIn: false,
@@ -472,16 +504,6 @@ export function mockStore(state: any = {}, reducer = (state: any) => state): Sto
   return createStore(reducer, state);
 }
 
-export function mockSourceLine(overrides: Partial<T.SourceLine> = {}): T.SourceLine {
-  return {
-    code: 'function fooBar() {',
-    coverageStatus: 'covered',
-    coveredConditions: 2,
-    line: 5,
-    ...overrides
-  };
-}
-
 export function mockDocumentationEntry(
   overrides: Partial<DocumentationEntry> = {}
 ): DocumentationEntry {
index d2839e1f4b50adab751b493a956867f5ac862e1b..6e84ffd3a439cadd2ec84b9903c1f7931ab3ba0a 100644 (file)
@@ -2243,6 +2243,8 @@ source_viewer.tooltip.no_information_about_tests=There is no extra information a
 source_viewer.load_more_code=Load More Code
 source_viewer.loading_more_code=Loading More Code...
 
+source_viewer.expand_above=Expand above
+source_viewer.expand_below=Expand below
 
 #------------------------------------------------------------------------------
 #