]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19174 Migrating code viewer to MIUI
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Thu, 11 May 2023 12:41:56 +0000 (14:41 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 1 Jun 2023 20:02:58 +0000 (20:02 +0000)
73 files changed:
server/sonar-web/design-system/src/components/DatePicker.tsx
server/sonar-web/design-system/src/components/DropdownMenu.tsx
server/sonar-web/design-system/src/components/DropdownToggler.tsx
server/sonar-web/design-system/src/components/IssueLocationMarker.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Link.tsx
server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap
server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons.tsx
server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/code-line/LineMarker.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/code-line/LineNumber.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/code-line/LineStyles.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/code-line/LineToken.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/theme/colors.ts
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/app/styles/sonar-colorizer.css [deleted file]
server/sonar-web/src/main/js/app/styles/sonar.ts
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx
server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx
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/LineCoverage.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts
server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts
server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts
server/sonar-web/src/main/js/components/controls/Tooltip.tsx
server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx
server/sonar-web/src/main/js/types/types.ts
server/sonar-web/tailwind-utilities.js
server/sonar-web/tailwind.base.config.js
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 601eae009712a03cfdaf4c6c47703b084dca1ab6..a09e488afc5ad785b94cd195d687d08ffc21f73b 100644 (file)
@@ -47,7 +47,7 @@ import { FocusOutHandler } from './FocusOutHandler';
 import { InputField } from './InputField';
 import { InputSelect } from './InputSelect';
 import { InteractiveIcon } from './InteractiveIcon';
-import OutsideClickHandler from './OutsideClickHandler';
+import { OutsideClickHandler } from './OutsideClickHandler';
 import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons';
 import { CloseIcon } from './icons/CloseIcon';
 import { Popup } from './popups';
index b9e430d762379320fa19c10aac6993a7979fefa3..d822cf684379a00f071b2b275d108701c66c21c4 100644 (file)
@@ -23,7 +23,6 @@ import classNames from 'classnames';
 import React from 'react';
 import tw from 'twin.macro';
 import { INPUT_SIZES } from '../helpers/constants';
-import { translate } from '../helpers/l10n';
 import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { InputSizeKeys, ThemedProps } from '../types/theme';
 import { Checkbox } from './Checkbox';
@@ -196,14 +195,15 @@ interface ItemCopyProps {
   children?: React.ReactNode;
   className?: string;
   copyValue: string;
+  tooltipOverlay: React.ReactNode;
 }
 
 export function ItemCopy(props: ItemCopyProps) {
-  const { children, className, copyValue } = props;
+  const { children, className, copyValue, tooltipOverlay } = props;
   return (
     <ClipboardBase>
       {({ setCopyButton, copySuccess }) => (
-        <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+        <Tooltip overlay={tooltipOverlay} visible={copySuccess}>
           <li role="none">
             <ItemButtonStyled
               className={className}
index e81ac54bbadbb53c8824ee356ebb208324f66f03..ee0f65e57331dfc8e7732649a183aa96aeb16b71 100644 (file)
@@ -19,7 +19,7 @@
  */
 import EscKeydownHandler from './EscKeydownHandler';
 import { FocusOutHandler } from './FocusOutHandler';
-import OutsideClickHandler from './OutsideClickHandler';
+import { OutsideClickHandler } from './OutsideClickHandler';
 import { Popup } from './popups';
 
 type PopupProps = Popup['props'];
diff --git a/server/sonar-web/design-system/src/components/IssueLocationMarker.tsx b/server/sonar-web/design-system/src/components/IssueLocationMarker.tsx
new file mode 100644 (file)
index 0000000..f9886fd
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import { forwardRef, LegacyRef } from 'react';
+import tw from 'twin.macro';
+import { themeColor, themeContrast } from '../helpers/theme';
+import { isDefined } from '../helpers/types';
+import { IssueLocationIcon } from './icons/IssueLocationIcon';
+
+interface Props {
+  className?: string;
+  onClick?: () => void;
+  selected: boolean;
+  text?: number | string;
+}
+
+function IssueLocationMarkerFunc(
+  { className, onClick, text, selected }: Props,
+  ref: LegacyRef<HTMLElement>
+) {
+  return (
+    <Marker
+      className={classNames(className, {
+        selected,
+        concealed: !isDefined(text),
+        'sw-cursor-pointer': isDefined(onClick),
+      })}
+      onClick={onClick}
+      ref={ref}
+    >
+      {isDefined(text) ? text : <IssueLocationIcon />}
+    </Marker>
+  );
+}
+
+export const IssueLocationMarker = forwardRef<HTMLElement, Props>(IssueLocationMarkerFunc);
+
+export const Marker = styled.span`
+  ${tw`sw-flex sw-grow-0 sw-items-center sw-justify-center`}
+  ${tw`sw-body-sm-highlight`}
+  ${tw`sw-rounded-1/2`}
+
+  height: 1.125rem;
+  color: ${themeContrast('codeLineLocationMarker')};
+  background-color: ${themeColor('codeLineLocationMarker')};
+
+  &.selected,
+  &:hover {
+    background-color: ${themeColor('codeLineLocationMarkerSelected')};
+  }
+
+  &:not(.concealed) {
+    ${tw`sw-px-1`}
+    ${tw`sw-self-start`}
+  }
+`;
index 8f1bb78e43f44378194371a6e1fb2e54ad167e5c..c11760152233d3d638b51e9bbef5040201b3a9d6 100644 (file)
@@ -207,3 +207,10 @@ export const StandoutLink = styled(StyledBaseLink)`
   }
 `;
 StandoutLink.displayName = 'StandoutLink';
+
+export const IssueIndicatorLink = styled(BaseLink)`
+  color: ${themeColor('codeLineMeta')};
+  text-decoration: none;
+
+  ${tw`sw-whitespace-nowrap`}
+`;
index fb33549c1d1e3797a9fc47a96b34aa2b5f49f988..b13afd47c9fd3833e3c7379059a2e544333deda3 100644 (file)
@@ -27,7 +27,7 @@ interface Props {
   onClickOutside: () => void;
 }
 
-export default class OutsideClickHandler extends React.Component<Props> {
+export class OutsideClickHandler extends React.Component<Props> {
   mounted = false;
 
   componentDidMount() {
diff --git a/server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx b/server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx
new file mode 100644 (file)
index 0000000..8e8d7b5
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeColor } from '../helpers/theme';
+
+export const SonarCodeColorizer = styled.div`
+  & pre {
+    ${tw`sw-code`}
+
+    color: ${themeColor('codeSyntaxBody')};
+  }
+
+  /* for example java annotations */
+  & .a {
+    color: ${themeColor('codeSyntaxAnnotations')};
+  }
+
+  /* constants */
+  & .c {
+    ${tw`sw-code-highlight`}
+
+    color: ${themeColor('codeSyntaxConstants')};
+  }
+
+  /* classic comment */
+  & .cd {
+    ${tw`sw-code-comment`}
+
+    color: ${themeColor('codeSyntaxComments')};
+  }
+
+  /* javadoc */
+  & .j {
+    ${tw`sw-code-comment`}
+
+    color: ${themeColor('codeSyntaxComments')};
+  }
+
+  /* C++ doc */
+  & .cppd {
+    ${tw`sw-code-comment`}
+
+    color: ${themeColor('codeSyntaxComments')};
+  }
+
+  /* keyword */
+  & .k {
+    ${tw`sw-code-highlight`}
+
+    color: ${themeColor('codeSyntaxKeyword')};
+  }
+
+  /* string */
+  & .s {
+    color: ${themeColor('codeSyntaxString')};
+  }
+
+  /* keyword light */
+  & .h {
+    color: ${themeColor('codeSyntaxKeywordLight')};
+  }
+
+  /* preprocessing directive */
+  & .p {
+    color: ${themeColor('codeSyntaxPreprocessingDirective')};
+  }
+`;
+SonarCodeColorizer.displayName = 'SonarCodeColorizer';
index 1e68ae1d22e375cbc6fd514dd64d439293796688..41da9ead3326721cb22ffaf7d39d9938364a889a 100644 (file)
@@ -32,8 +32,8 @@ import {
   ItemNavLink,
   ItemRadioButton,
 } from '../DropdownMenu';
-import { MenuIcon } from '../icons/MenuIcon';
 import Tooltip from '../Tooltip';
+import { MenuIcon } from '../icons/MenuIcon';
 
 beforeEach(() => {
   jest.useFakeTimers();
@@ -88,7 +88,9 @@ function renderDropdownMenu() {
         Button
       </ItemButton>
       <ItemDangerButton onClick={noop}>DangerButton</ItemDangerButton>
-      <ItemCopy copyValue="copy">Copy</ItemCopy>
+      <ItemCopy copyValue="copy" tooltipOverlay="overlay">
+        Copy
+      </ItemCopy>
       <ItemCheckbox checked={true} onCheck={noop}>
         Checkbox item
       </ItemCheckbox>
diff --git a/server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx
new file mode 100644 (file)
index 0000000..5fa1bc6
--- /dev/null
@@ -0,0 +1,58 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { LineCoverage } from '../code-line/LineCoverage';
+
+it('should render correctly when covered', () => {
+  expect(setupWithProps().container).toMatchSnapshot();
+});
+
+it('should render correctly when uncovered', () => {
+  expect(
+    setupWithProps({ lineNumber: 16, coverageStatus: 'uncovered' }).container
+  ).toMatchSnapshot();
+});
+
+it('should render correctly when partially covered without conditions', () => {
+  expect(
+    setupWithProps({
+      lineNumber: 16,
+      coverageStatus: 'partially-covered',
+    }).container
+  ).toMatchSnapshot();
+});
+
+it('should render correctly when partially covered with 5/10 conditions', () => {
+  expect(
+    setupWithProps({
+      lineNumber: 16,
+      coverageStatus: 'partially-covered',
+    }).container
+  ).toMatchSnapshot();
+});
+
+it('should render correctly when no data', () => {
+  expect(setupWithProps({ lineNumber: 16, coverageStatus: undefined }).container).toMatchSnapshot();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof LineCoverage>> = {}) {
+  return render(<LineCoverage coverageStatus="covered" lineNumber={16} status="OK" {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx
new file mode 100644 (file)
index 0000000..aee3144
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { LineNumber } from '../code-line/LineNumber';
+
+it('should a popup when clicked', async () => {
+  const { user } = setupWithProps();
+
+  expect(screen.getByRole('button', { name: 'aria-label' })).toBeVisible();
+
+  await user.click(screen.getByRole('button', { name: 'aria-label' }));
+  expect(screen.getByText('Popup')).toBeVisible();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof LineNumber>> = {}) {
+  return render(
+    <LineNumber
+      ariaLabel="aria-label"
+      displayOptions={true}
+      firstLineNumber={1}
+      lineNumber={16}
+      popup={<div>Popup</div>}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx
new file mode 100644 (file)
index 0000000..0bdc69d
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { LineWrapper } from '../code-line/LineWrapper';
+
+it('should render with correct styling', () => {
+  expect(setupWithProps().container).toMatchSnapshot();
+});
+
+it('should properly setup css grid columns', () => {
+  expect(setupWithProps().container.firstChild).toHaveStyle({
+    '--columns': '44px 50px 26px repeat(3, 6px) 1fr',
+  });
+  expect(setupWithProps({ duplicationsCount: 0 }).container.firstChild).toHaveStyle({
+    '--columns': '44px 50px 26px repeat(1, 6px) 1fr',
+  });
+  expect(
+    setupWithProps({ displayCoverage: false, displaySCM: false, duplicationsCount: 0 }).container
+      .firstChild
+  ).toHaveStyle({ '--columns': '44px 26px 1fr' });
+});
+
+it('should set a highlighted background color in css props', () => {
+  const { container } = setupWithProps({ highlighted: true });
+  expect(container.firstChild).toHaveStyle({ '--line-background': 'rgb(225,230,243)' });
+});
+
+function setupWithProps(props: Partial<FCProps<typeof LineWrapper>> = {}) {
+  return render(
+    <LineWrapper
+      displayCoverage={true}
+      displaySCM={true}
+      duplicationsCount={2}
+      highlighted={false}
+      {...props}
+    />
+  );
+}
index 5b27cf29f1f6ed44bfc0f62d45550f8eec123451..ec00317fed2b83cef41c2337d825118163f0b4c9 100644 (file)
@@ -112,7 +112,7 @@ exports[`should show full size when multiline with no editting 1`] = `
   background: rgb(252,252,253);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 400;
 }
 
@@ -136,7 +136,7 @@ exports[`should show full size when multiline with no editting 1`] = `
   color: rgb(109,111,119);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-style: italic;
 }
 
@@ -146,7 +146,7 @@ exports[`should show full size when multiline with no editting 1`] = `
   color: rgb(152,29,150);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 700;
 }
 
@@ -353,7 +353,7 @@ exports[`should show reduced size when single line with no editting 1`] = `
   background: rgb(252,252,253);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 400;
 }
 
@@ -377,7 +377,7 @@ exports[`should show reduced size when single line with no editting 1`] = `
   color: rgb(109,111,119);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-style: italic;
 }
 
@@ -387,7 +387,7 @@ exports[`should show reduced size when single line with no editting 1`] = `
   color: rgb(152,29,150);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 700;
 }
 
index 45b4dd7b63bf61744c1b60576f6200470ef6e020..d7fed0582253b5d9b2c9c79b0f85c4ee51855724 100644 (file)
@@ -19,7 +19,7 @@ exports[`renders correctly 1`] = `
   background: rgb(252,252,253);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 400;
 }
 
@@ -43,7 +43,7 @@ exports[`renders correctly 1`] = `
   color: rgb(109,111,119);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-style: italic;
 }
 
@@ -53,7 +53,7 @@ exports[`renders correctly 1`] = `
   color: rgb(152,29,150);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 700;
 }
 
@@ -127,7 +127,7 @@ exports[`should display edit functions 1`] = `
   background: rgb(252,252,253);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 400;
 }
 
@@ -151,7 +151,7 @@ exports[`should display edit functions 1`] = `
   color: rgb(109,111,119);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-style: italic;
 }
 
@@ -161,7 +161,7 @@ exports[`should display edit functions 1`] = `
   color: rgb(152,29,150);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 700;
 }
 
@@ -321,7 +321,7 @@ exports[`should handle multiple lines of code 1`] = `
   background: rgb(252,252,253);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 400;
 }
 
@@ -345,7 +345,7 @@ exports[`should handle multiple lines of code 1`] = `
   color: rgb(109,111,119);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-style: italic;
 }
 
@@ -355,7 +355,7 @@ exports[`should handle multiple lines of code 1`] = `
   color: rgb(152,29,150);
   font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
   font-size: 0.875rem;
-  line-height: 1.25rem;
+  line-height: 1.125rem;
   font-weight: 700;
 }
 
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap
new file mode 100644 (file)
index 0000000..8846269
--- /dev/null
@@ -0,0 +1,237 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly when covered 1`] = `
+.emotion-0 {
+  color: rgb(166,173,194);
+  background-color: var(--line-background);
+  outline: none;
+  height: 100%;
+  width: 100%;
+  box-sizing: border-box;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.e97pm2l12:hover .emotion-0 {
+  background-color: rgb(239,242,249);
+}
+
+.emotion-2 {
+  height: 100%;
+  width: 0.25rem;
+  margin-left: 0.125rem;
+  background-color: rgb(166,244,197);
+}
+
+.emotion-2,
+.emotion-2 svg {
+  outline: none;
+}
+
+<div>
+  <td
+    aria-describedby="tooltip-1"
+    class="emotion-0 emotion-1"
+    data-line-number="16"
+  >
+    <div
+      aria-label="OK"
+      class="emotion-2 emotion-3"
+    />
+  </td>
+</div>
+`;
+
+exports[`should render correctly when no data 1`] = `
+.emotion-0 {
+  color: rgb(166,173,194);
+  background-color: var(--line-background);
+  outline: none;
+  height: 100%;
+  width: 100%;
+  box-sizing: border-box;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.e97pm2l12:hover .emotion-0 {
+  background-color: rgb(239,242,249);
+}
+
+<div>
+  <td
+    class="emotion-0 emotion-1"
+    data-line-number="16"
+  />
+</div>
+`;
+
+exports[`should render correctly when partially covered with 5/10 conditions 1`] = `
+.emotion-0 {
+  color: rgb(166,173,194);
+  background-color: var(--line-background);
+  outline: none;
+  height: 100%;
+  width: 100%;
+  box-sizing: border-box;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.e97pm2l12:hover .emotion-0 {
+  background-color: rgb(239,242,249);
+}
+
+.emotion-2 {
+  height: 100%;
+  width: 0.25rem;
+  margin-left: 0.125rem;
+}
+
+.emotion-2,
+.emotion-2 svg {
+  outline: none;
+}
+
+<div>
+  <td
+    aria-describedby="tooltip-4"
+    class="emotion-0 emotion-1"
+    data-line-number="16"
+  >
+    <div
+      aria-label="OK"
+      class="emotion-2 emotion-3"
+    >
+      <svg
+        fill="none"
+        viewBox="0 0 4 18"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <rect
+          fill="rgb(217,45,32)"
+          height="18"
+          width="4"
+        />
+        <path
+          clip-rule="evenodd"
+          d="M0 0L4 3V6L0 3V0ZM0 6L4 9V12L0 9V6ZM4 15L0 12V15L4 18V15Z"
+          fill="rgb(255,255,255)"
+          fill-rule="evenodd"
+        />
+      </svg>
+    </div>
+  </td>
+</div>
+`;
+
+exports[`should render correctly when partially covered without conditions 1`] = `
+.emotion-0 {
+  color: rgb(166,173,194);
+  background-color: var(--line-background);
+  outline: none;
+  height: 100%;
+  width: 100%;
+  box-sizing: border-box;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.e97pm2l12:hover .emotion-0 {
+  background-color: rgb(239,242,249);
+}
+
+.emotion-2 {
+  height: 100%;
+  width: 0.25rem;
+  margin-left: 0.125rem;
+}
+
+.emotion-2,
+.emotion-2 svg {
+  outline: none;
+}
+
+<div>
+  <td
+    aria-describedby="tooltip-3"
+    class="emotion-0 emotion-1"
+    data-line-number="16"
+  >
+    <div
+      aria-label="OK"
+      class="emotion-2 emotion-3"
+    >
+      <svg
+        fill="none"
+        viewBox="0 0 4 18"
+        xmlns="http://www.w3.org/2000/svg"
+      >
+        <rect
+          fill="rgb(217,45,32)"
+          height="18"
+          width="4"
+        />
+        <path
+          clip-rule="evenodd"
+          d="M0 0L4 3V6L0 3V0ZM0 6L4 9V12L0 9V6ZM4 15L0 12V15L4 18V15Z"
+          fill="rgb(255,255,255)"
+          fill-rule="evenodd"
+        />
+      </svg>
+    </div>
+  </td>
+</div>
+`;
+
+exports[`should render correctly when uncovered 1`] = `
+.emotion-0 {
+  color: rgb(166,173,194);
+  background-color: var(--line-background);
+  outline: none;
+  height: 100%;
+  width: 100%;
+  box-sizing: border-box;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+}
+
+.e97pm2l12:hover .emotion-0 {
+  background-color: rgb(239,242,249);
+}
+
+.emotion-2 {
+  height: 100%;
+  width: 0.25rem;
+  margin-left: 0.125rem;
+  background-color: rgb(217,45,32);
+}
+
+.emotion-2,
+.emotion-2 svg {
+  outline: none;
+}
+
+<div>
+  <td
+    aria-describedby="tooltip-2"
+    class="emotion-0 emotion-1"
+    data-line-number="16"
+  >
+    <div
+      aria-label="OK"
+      class="emotion-2 emotion-3"
+    />
+  </td>
+</div>
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap
new file mode 100644 (file)
index 0000000..2bbcd9f
--- /dev/null
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render with correct styling 1`] = `
+.emotion-0 {
+  display: grid;
+  grid-template-rows: auto;
+  grid-template-columns: var(--columns);
+  -webkit-align-items: center;
+  -webkit-box-align: center;
+  -ms-flex-align: center;
+  align-items: center;
+  font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
+  font-size: 0.875rem;
+  line-height: 1.125rem;
+  font-weight: 400;
+}
+
+<div>
+  <tr
+    class="emotion-0 emotion-1"
+    style="--columns: 44px 50px 26px repeat(3, 6px) 1fr; --line-background: rgb(255,255,255);"
+  />
+</div>
+`;
index 6e955a2bb049907f6a505d81da5e0746a088ab69..dd679d3ae563bed1bbae26ed36c7144aa624a218 100644 (file)
@@ -255,3 +255,34 @@ export const CodeViewerExpander = styled(BareButton)<CodeViewerExpanderProps>`
   border-bottom: ${(props) =>
     props.direction === 'UP' ? themeBorder('default', 'codeLineBorder') : 'none'};
 `;
+
+export const IssueIndicatorButton = styled(BareButton)`
+  color: ${themeColor('codeLineMeta')};
+  text-decoration: none;
+
+  ${tw`sw-whitespace-nowrap`}
+`;
+
+export const DuplicationBlock = styled(BareButton)`
+  background-color: ${themeColor('codeLineDuplication')};
+  outline: none;
+
+  ${tw`sw-block`}
+  ${tw`sw-w-1 sw-h-full`}
+  ${tw`sw-ml-1/2`}
+  ${tw`sw-cursor-pointer`}
+`;
+
+export const LineSCMStyled = styled(BareButton)`
+  outline: none;
+
+  ${tw`sw-pr-2`}
+  ${tw`sw-truncate`}
+  ${tw`sw-whitespace-nowrap`}
+  ${tw`sw-cursor-pointer`}
+  ${tw`sw-w-full sw-h-full`}
+
+&:hover {
+    color: ${themeColor('codeLineMetaHover')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx b/server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx
new file mode 100644 (file)
index 0000000..726b354
--- /dev/null
@@ -0,0 +1,97 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import React, { memo } from 'react';
+import tw from 'twin.macro';
+import { PopupPlacement } from '../../helpers/positioning';
+import { themeColor } from '../../helpers/theme';
+import Tooltip from '../Tooltip';
+import { LineMeta } from './LineStyles';
+
+interface Props {
+  coverageStatus?: 'uncovered' | 'partially-covered' | 'covered';
+  lineNumber: number;
+  scrollToUncoveredLine?: boolean;
+  status: string | undefined;
+}
+
+function LineCoverageFunc({ lineNumber, coverageStatus, status, scrollToUncoveredLine }: Props) {
+  const coverageMarker = React.useRef<HTMLTableCellElement>(null);
+  React.useEffect(() => {
+    if (scrollToUncoveredLine && coverageMarker.current) {
+      coverageMarker.current.scrollIntoView({
+        behavior: 'smooth',
+        block: 'center',
+        inline: 'center',
+      });
+    }
+  }, [scrollToUncoveredLine, coverageMarker]);
+
+  if (!coverageStatus) {
+    return <LineMeta data-line-number={lineNumber} />;
+  }
+
+  return (
+    <Tooltip overlay={status} placement={PopupPlacement.Right}>
+      <LineMeta data-line-number={lineNumber} ref={coverageMarker}>
+        {coverageStatus === 'covered' && <CoveredBlock aria-label={status} />}
+        {coverageStatus === 'uncovered' && <UncoveredBlock aria-label={status} />}
+        {coverageStatus === 'partially-covered' && <PartiallyCoveredBlock aria-label={status} />}
+      </LineMeta>
+    </Tooltip>
+  );
+}
+
+export const LineCoverage = memo(LineCoverageFunc);
+
+const CoverageBlock = styled.div`
+  ${tw`sw-w-1 sw-h-full`}
+  ${tw`sw-ml-1/2`}
+
+  &, & svg {
+    outline: none;
+  }
+`;
+
+const CoveredBlock = styled(CoverageBlock)`
+  background-color: ${themeColor('codeLineCovered')};
+`;
+
+const UncoveredBlock = styled(CoverageBlock)`
+  background-color: ${themeColor('codeLineUncovered')};
+`;
+
+function PartiallyCoveredBlock(htmlProps: React.HTMLAttributes<HTMLDivElement>) {
+  const theme = useTheme();
+  return (
+    <CoverageBlock {...htmlProps}>
+      <svg fill="none" viewBox="0 0 4 18" xmlns="http://www.w3.org/2000/svg">
+        <rect fill={themeColor('codeLinePartiallyCoveredA')({ theme })} height="18" width="4" />
+        <path
+          clipRule="evenodd"
+          d="M0 0L4 3V6L0 3V0ZM0 6L4 9V12L0 9V6ZM4 15L0 12V15L4 18V15Z"
+          fill={themeColor('codeLinePartiallyCoveredB')({ theme })}
+          fillRule="evenodd"
+        />
+      </svg>
+    </CoverageBlock>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx b/server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx
new file mode 100644 (file)
index 0000000..a552296
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import { memo } from 'react';
+import tw from 'twin.macro';
+import { IssueTypeIcon } from '../icons/IssueTypeIcon';
+
+export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT';
+
+interface Props {
+  issuesCount: number;
+  mostImportantIssueType: IssueType;
+}
+
+function LineIssueIndicatorIconFunc({ issuesCount, mostImportantIssueType }: Props) {
+  return (
+    <>
+      <IssueTypeIcon type={mostImportantIssueType} />
+      {issuesCount > 1 && <IssueIndicatorCounter>{issuesCount}</IssueIndicatorCounter>}
+    </>
+  );
+}
+
+export const LineIssuesIndicatorIcon = memo(LineIssueIndicatorIconFunc);
+
+const IssueIndicatorCounter = styled.span`
+  font-size: 0.5rem;
+  line-height: 0.5rem;
+
+  ${tw`sw-ml-1/2`}
+  ${tw`sw-align-top`}
+`;
diff --git a/server/sonar-web/design-system/src/components/code-line/LineMarker.tsx b/server/sonar-web/design-system/src/components/code-line/LineMarker.tsx
new file mode 100644 (file)
index 0000000..cf62c59
--- /dev/null
@@ -0,0 +1,98 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import { forwardRef, Ref, useCallback, useRef } from 'react';
+import tw from 'twin.macro';
+import { themeColor, themeContrast } from '../../helpers/theme';
+import { IssueLocationMarker } from '../IssueLocationMarker';
+
+interface Props {
+  hideLocationIndex?: boolean;
+  index: number;
+  leading?: boolean;
+  message?: string;
+  onLocationSelect?: (index: number) => void;
+  selected: boolean;
+}
+
+function LineMarkerFunc(
+  { hideLocationIndex, index, leading, message, onLocationSelect, selected }: Props,
+  ref: Ref<HTMLElement>
+) {
+  const element = useRef<HTMLDivElement | null>(null);
+  const elementMessage = useRef<HTMLDivElement | null>(null);
+
+  const handleClick = useCallback(() => {
+    onLocationSelect?.(index);
+  }, [index, onLocationSelect]);
+
+  return (
+    <Wrapper className={classNames({ leading })} ref={element}>
+      <IssueLocationMarker
+        onClick={handleClick}
+        ref={ref}
+        selected={selected}
+        text={hideLocationIndex ? undefined : index + 1}
+      />
+      {message && <Message ref={elementMessage}>{message}</Message>}
+    </Wrapper>
+  );
+}
+
+const Message = styled.div`
+  ${tw`sw-absolute`}
+  ${tw`sw-body-sm`}
+  ${tw`sw-rounded-1/2`}
+  ${tw`sw-px-1`}
+  ${tw`sw-left-0`}
+
+  bottom: calc(100% + 0.25rem);
+  width: max-content;
+  max-width: var(--max-width);
+  color: ${themeContrast('codeLineIssueMessageTooltip')};
+  background-color: ${themeColor('codeLineIssueMessageTooltip')};
+  visibility: hidden;
+
+  &.message-right {
+    ${tw`sw-left-auto`}
+    ${tw`sw-right-0`}
+  }
+`;
+
+const Wrapper = styled.div`
+  ${tw`sw-relative`}
+  ${tw`sw-inline-block`}
+  ${tw`sw-align-top`}
+
+  &:not(:first-of-type) {
+    ${tw`sw-ml-1`}
+  }
+
+  &.leading {
+    margin-left: calc(var(--width) - 0.25rem);
+  }
+
+  &:hover ${Message} {
+    visibility: visible;
+  }
+`;
+
+export const LineMarker = forwardRef<HTMLElement, Props>(LineMarkerFunc);
diff --git a/server/sonar-web/design-system/src/components/code-line/LineNumber.tsx b/server/sonar-web/design-system/src/components/code-line/LineNumber.tsx
new file mode 100644 (file)
index 0000000..243b2df
--- /dev/null
@@ -0,0 +1,95 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import { memo, useState } from 'react';
+import tw from 'twin.macro';
+import { PopupPlacement, PopupZLevel } from '../../helpers/positioning';
+import { themeColor } from '../../helpers/theme';
+import { DropdownToggler } from '../DropdownToggler';
+import { LineMeta } from './LineStyles';
+
+interface Props {
+  ariaLabel: string;
+  displayOptions: boolean;
+  firstLineNumber: number;
+  lineNumber: number;
+  popup: React.ReactNode;
+}
+
+const FILE_TOP_THRESHOLD = 10;
+
+function LineNumberFunc({ firstLineNumber, lineNumber, popup, displayOptions, ariaLabel }: Props) {
+  const [isOpen, setIsOpen] = useState<boolean>(false);
+
+  const hasLineNumber = Boolean(lineNumber);
+  const isFileTop = lineNumber - FILE_TOP_THRESHOLD < firstLineNumber;
+
+  if (!hasLineNumber) {
+    return <LineMeta className="sw-pl-2" />;
+  }
+
+  return (
+    <LineMeta className="sw-pl-2" data-line-number={lineNumber}>
+      {displayOptions ? (
+        <DropdownToggler
+          aria-labelledby={`line-number-trigger-${lineNumber}`}
+          id={`line-number-dropdown-${lineNumber}`}
+          onRequestClose={() => {
+            setIsOpen(false);
+          }}
+          open={isOpen}
+          overlay={popup}
+          placement={isFileTop ? PopupPlacement.Bottom : PopupPlacement.Top}
+          zLevel={PopupZLevel.Global}
+        >
+          <LineNumberStyled
+            aria-controls={`line-number-dropdown-${lineNumber}`}
+            aria-expanded={isOpen}
+            aria-haspopup="menu"
+            aria-label={ariaLabel}
+            id={`line-number-trigger-${lineNumber}`}
+            onClick={() => {
+              setIsOpen(true);
+            }}
+            role="button"
+            tabIndex={0}
+          >
+            {lineNumber}
+          </LineNumberStyled>
+        </DropdownToggler>
+      ) : (
+        lineNumber
+      )}
+    </LineMeta>
+  );
+}
+
+export const LineNumber = memo(LineNumberFunc);
+
+const LineNumberStyled = styled.div`
+  outline: none;
+
+  ${tw`sw-pr-2`}
+  ${tw`sw-cursor-pointer`}
+
+  &:hover {
+    color: ${themeColor('codeLineMetaHover')};
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx b/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx
new file mode 100644 (file)
index 0000000..88975e2
--- /dev/null
@@ -0,0 +1,145 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+
+export const SCMHighlight = styled.h6`
+  color: ${themeColor('tooltipHighlight')};
+
+  ${tw`sw-body-sm-highlight`};
+  ${tw`sw-text-right`};
+  ${tw`sw-min-w-[6rem]`};
+  ${tw`sw-mr-4`};
+  ${tw`sw-my-1`};
+`;
+
+export const LineSCMStyledDiv = styled.div`
+  outline: none;
+
+  ${tw`sw-pr-2`}
+  ${tw`sw-truncate`}
+${tw`sw-whitespace-nowrap`}
+${tw`sw-cursor-pointer`}
+${tw`sw-w-full sw-h-full`}
+
+&:hover {
+    color: ${themeColor('codeLineMetaHover')};
+  }
+`;
+
+export const DuplicationHighlight = styled.h6`
+  color: ${themeColor('tooltipHighlight')};
+
+  ${tw`sw-mb-2 sw-font-semibold`};
+`;
+
+export const LineStyled = styled.tr`
+  display: grid;
+  grid-template-rows: auto;
+  grid-template-columns: var(--columns);
+  align-items: center;
+
+  ${tw`sw-code`}
+`;
+LineStyled.displayName = 'LineStyled';
+
+export const LineMeta = styled.td`
+  color: ${themeColor('codeLineMeta')};
+  background-color: var(--line-background);
+  outline: none;
+
+  ${tw`sw-w-full sw-h-full`}
+  ${tw`sw-box-border`}
+  ${tw`sw-select-none`}
+
+  ${LineStyled}:hover & {
+    background-color: ${themeColor('codeLineHover')};
+  }
+`;
+
+export const LineCodePreFormatted = styled.pre`
+  position: relative;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+  tab-size: 4;
+`;
+
+export const LineCodeLayer = styled.div`
+  grid-row: 1;
+  grid-column: 1;
+`;
+
+export const LineCodeLayers = styled.td`
+  position: relative;
+  display: grid;
+  height: 100%;
+  background-color: var(--line-background);
+  border-left: ${themeBorder('default', 'codeLineBorder')};
+
+  ${LineStyled}:hover & {
+    background-color: ${themeColor('codeLineHover')};
+  }
+`;
+
+export const NewCodeUnderline = styled(LineCodeLayer)`
+  background-color: ${themeColor('codeLineNewCodeUnderline')};
+`;
+
+export const CoveredUnderline = styled(LineCodeLayer)`
+  background-color: ${themeColor('codeLineCoveredUnderline')};
+`;
+
+export const UncoveredUnderline = styled(LineCodeLayer)`
+  background-color: ${themeColor('codeLineUncoveredUnderline')};
+`;
+
+export const UnderlineLabels = styled.div<{ transparentBackground?: boolean }>`
+  ${tw`sw-absolute`}
+  ${tw`sw-flex sw-gap-1`}
+  ${tw`sw-px-1`}
+  ${tw`sw-right-0`}
+
+
+  height: 1.125rem;
+  margin-top: -1.125rem;
+  background-color: ${({ transparentBackground, theme }) =>
+    themeColor(transparentBackground ? 'transparent' : 'codeLine')({ theme })};
+`;
+
+export const UnderlineLabel = styled.span`
+  ${tw`sw-rounded-t-1`}
+  ${tw`sw-px-1`}
+`;
+
+export const NewCodeUnderlineLabel = styled(UnderlineLabel)`
+  color: ${themeContrast('codeLineNewCodeUnderline')};
+  background-color: ${themeColor('codeLineNewCodeUnderline')};
+`;
+
+export const CoveredUnderlineLabel = styled(UnderlineLabel)`
+  color: ${themeContrast('codeLineCoveredUnderline')};
+  background-color: ${themeColor('codeLineCoveredUnderline')};
+`;
+
+export const UncoveredUnderlineLabel = styled(UnderlineLabel)`
+  color: ${themeContrast('codeLineUncoveredUnderline')};
+  background-color: ${themeColor('codeLineUncoveredUnderline')};
+`;
diff --git a/server/sonar-web/design-system/src/components/code-line/LineToken.tsx b/server/sonar-web/design-system/src/components/code-line/LineToken.tsx
new file mode 100644 (file)
index 0000000..b96667a
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import { ReactNode } from 'react';
+import tw from 'twin.macro';
+import { themeColor } from '../../helpers/theme';
+
+export interface TokenModifiers {
+  isHighlighted?: boolean;
+  isLocation?: boolean;
+  isSelected?: boolean;
+  isUnderlined?: boolean;
+}
+
+interface Props extends TokenModifiers {
+  children: ReactNode;
+  className?: string;
+  hasMarker?: boolean;
+}
+
+export function LineToken(props: Props) {
+  const { children, className, hasMarker, ...modifiers } = props;
+
+  return (
+    <TokenStyled
+      className={classNames(className, {
+        'issue-underline': modifiers.isUnderlined,
+        'issue-location': modifiers.isLocation,
+        highlighted: modifiers.isHighlighted,
+        selected: modifiers.isSelected,
+        'has-marker': hasMarker,
+      })}
+    >
+      <>{children}</>
+    </TokenStyled>
+  );
+}
+
+const TokenStyled = styled.span`
+  display: inline-block;
+
+  &.sym {
+    ${tw`sw-cursor-pointer`}
+  }
+
+  &.sym.highlighted {
+    background-color: ${themeColor('codeLineLocationHighlighted')};
+    transition: background-color 0.3s;
+  }
+
+  &.issue-underline {
+    position: relative;
+    z-index: 1;
+    text-decoration: underline ${themeColor('codeLineIssueSquiggle')};
+    text-decoration: underline ${themeColor('codeLineIssueSquiggle')} wavy;
+    text-decoration-thickness: 2px;
+    text-decoration-skip-ink: none;
+  }
+
+  &.issue-location {
+    line-height: 1.125rem;
+    background-color: ${themeColor('codeLineIssueLocation')};
+    transition: background-color 0.3s ease;
+  }
+
+  &.issue-location.selected {
+    background-color: ${themeColor('codeLineIssueLocationSelected')};
+  }
+
+  &.issue-location.has-marker {
+    ${tw`sw-pl-1`}
+  }
+`;
diff --git a/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx b/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx
new file mode 100644 (file)
index 0000000..5b825c8
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import { HTMLAttributes } from 'react';
+import { themeColor } from '../../helpers/theme';
+import { LineStyled } from './LineStyles';
+
+interface Props extends HTMLAttributes<HTMLDivElement> {
+  displayCoverage: boolean;
+  displaySCM: boolean;
+  duplicationsCount: number;
+  highlighted: boolean;
+}
+
+export function LineWrapper(props: Props) {
+  const { displayCoverage, displaySCM, duplicationsCount, highlighted, ...htmlProps } = props;
+  const theme = useTheme();
+  const SCMCol = displaySCM ? '50px ' : '';
+  const nbGutters = duplicationsCount + (displayCoverage ? 1 : 0);
+  const gutterCols = nbGutters > 0 ? `repeat(${nbGutters}, 6px) ` : '';
+  return (
+    <LineStyled
+      style={{
+        '--columns': `44px ${SCMCol}26px ${gutterCols}1fr`,
+        '--line-background': highlighted
+          ? themeColor('codeLineHighlighted')({ theme })
+          : themeColor('codeLine')({ theme }),
+      }}
+      {...htmlProps}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx b/server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx
new file mode 100644 (file)
index 0000000..134788f
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeColor, themeContrast } from '../../helpers/theme';
+import { BugIcon } from './BugIcon';
+import { CodeSmellIcon } from './CodeSmellIcon';
+import { IconProps } from './Icon';
+import { SecurityFindingIcon } from './SecurityFindingIcon';
+import { VulnerabilityIcon } from './VulnerabilityIcon';
+
+export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT';
+export interface Props extends IconProps {
+  // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
+  type: string | IssueType;
+}
+
+export enum IssueTypeEnum {
+  CODE_SMELL = 'CODE_SMELL',
+  VULNERABILITY = 'VULNERABILITY',
+  BUG = 'BUG',
+  SECURITY_HOTSPOT = 'SECURITY_HOTSPOT',
+}
+
+export function IssueTypeIcon({ type, ...iconProps }: Props) {
+  switch (type.toLowerCase()) {
+    case IssueTypeEnum.BUG.toLowerCase():
+    case 'bugs':
+    case 'new_bugs':
+    case IssueTypeEnum.BUG:
+      return <BugIcon {...iconProps} />;
+    case IssueTypeEnum.VULNERABILITY.toLowerCase():
+    case 'vulnerabilities':
+    case 'new_vulnerabilities':
+    case IssueTypeEnum.VULNERABILITY:
+      return <VulnerabilityIcon {...iconProps} />;
+    case IssueTypeEnum.CODE_SMELL.toLowerCase():
+    case 'code_smells':
+    case 'new_code_smells':
+    case IssueTypeEnum.CODE_SMELL:
+      return <CodeSmellIcon {...iconProps} />;
+    case IssueTypeEnum.SECURITY_HOTSPOT.toLowerCase():
+    case 'security_hotspots':
+    case 'new_security_hotspots':
+    case IssueTypeEnum.SECURITY_HOTSPOT:
+      return <SecurityFindingIcon {...iconProps} />;
+    default:
+      return null;
+  }
+}
+
+export function IssueTypeCircleIcon({ className, type, ...iconProps }: Props) {
+  const theme = useTheme();
+  return (
+    <CircleIconContainer className={className}>
+      <IssueTypeIcon fill={themeContrast('issueTypeIcon')({ theme })} type={type} {...iconProps} />
+    </CircleIconContainer>
+  );
+}
+
+const CircleIconContainer = styled.div`
+  ${tw`sw-w-6 sw-h-6`}
+  ${tw`sw-inline-flex sw-items-center sw-justify-center sw-shrink-0`};
+
+  background: ${themeColor('issueTypeIcon')};
+  border-radius: 100%;
+`;
diff --git a/server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx b/server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx
new file mode 100644 (file)
index 0000000..4f5bbc4
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { DirectoryIcon } from './DirectoryIcon';
+import { FileIcon } from './FileIcon';
+import { IconProps } from './Icon';
+import { ProjectIcon } from './ProjectIcon';
+import { TestFileIcon } from './TestFileIcon';
+
+interface Props extends IconProps {
+  qualifier: string | null | undefined;
+}
+
+export function QualifierIcon({ qualifier, fill, ...iconProps }: Props) {
+  const theme = useTheme();
+
+  if (!qualifier) {
+    return null;
+  }
+
+  const icon = {
+    dir: <DirectoryIcon fill={fill ?? themeColor('iconDirectory')({ theme })} {...iconProps} />,
+    fil: <FileIcon fill={fill ?? themeColor('iconFile')({ theme })} {...iconProps} />,
+    trk: <ProjectIcon fill={fill ?? themeColor('iconProject')({ theme })} {...iconProps} />,
+    uts: <TestFileIcon fill={fill ?? themeColor('iconProject')({ theme })} {...iconProps} />,
+  }[qualifier.toLowerCase()];
+
+  return icon ?? null;
+}
diff --git a/server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx b/server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx
new file mode 100644 (file)
index 0000000..70bf1d3
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export function SecurityFindingIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+  const theme = useTheme();
+  const fillColor = themeColor(fill)({ theme });
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M13.2114 3.76857a.8571.8571 0 0 0-.5743-.66L8.13714 1.85714a.90869.90869 0 0 0-.42857 0l-4.5 1.25143c-.3046.08786-.52937.34618-.57429.66-.06857.48857-.63428 4.82572.97715 7.12283a7.7138 7.7138 0 0 0 4.11428 2.9657.72308.72308 0 0 0 .19714 0 .66187.66187 0 0 0 .18857 0 7.65392 7.65392 0 0 0 4.12288-2.9657c1.5732-2.27896 1.0457-6.56583.9786-7.11053l-.0015-.0123Zm-1.6028 4.08857a5.27096 5.27096 0 0 1-.7372 2.07429A6.78813 6.78813 0 0 1 8 12.1429V7.85714h3.6086Zm-3.6086 0H4.22a20.81886 20.81886 0 0 1 0-3.27428L8 3.57143v4.28571Z"
+        fill={fillColor}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx b/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx
new file mode 100644 (file)
index 0000000..fae7278
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import { themeColor } from '../../helpers/theme';
+import { CustomIcon, IconProps } from './Icon';
+
+export function TestFileIcon({ fill = 'currentColor', ...iconProps }: IconProps) {
+  const theme = useTheme();
+  const fillColor = themeColor(fill)({ theme });
+  return (
+    <CustomIcon {...iconProps}>
+      <path
+        clipRule="evenodd"
+        d="M3.75 1.5a.25.25 0 0 0-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75Zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06ZM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75Z"
+        fill={fillColor}
+        fillRule="evenodd"
+      />
+      <path
+        clipRule="evenodd"
+        d="M8.605 11.528v-1.514l-.016-1.486.016-1.058 2.544 2.544-2.544 2.544v-1.03ZM7.545 8.5v1.514L7.56 11.5l-.017 1.058L5 10.014 7.544 7.47V8.5Z"
+        fill={fillColor}
+        fillRule="evenodd"
+      />
+    </CustomIcon>
+  );
+}
index 492863fb77823c3bc53dd4e3415dacdf5fba3a7f..b811ad0071dca111d3aeb373557b098c31f18e99 100644 (file)
@@ -53,6 +53,7 @@ export { OverviewQGPassedIcon } from './OverviewQGPassedIcon';
 export { PencilIcon } from './PencilIcon';
 export { ProjectIcon } from './ProjectIcon';
 export { PullRequestIcon } from './PullRequestIcon';
+export { QualifierIcon } from './QualifierIcon';
 export { RefreshIcon } from './RefreshIcon';
 export { RequiredIcon } from './RequiredIcon';
 export { SecurityHotspotIcon } from './SecurityHotspotIcon';
index f1880121fdeb0289bc4bf1df75f5a0bf6fe74dd4..fb80d478c1a29b5ec7bc8965a8127e2e3b598bff 100644 (file)
@@ -62,12 +62,14 @@ export * from './MainMenuItem';
 export * from './MetricsRatingBadge';
 export * from './NavBarTabs';
 export * from './NewCodeLegend';
+export * from './OutsideClickHandler';
 export { QualityGateIndicator } from './QualityGateIndicator';
 export * from './SearchSelect';
 export * from './SearchSelectDropdown';
 export * from './SelectionCard';
 export * from './Separator';
 export * from './SizeIndicator';
+export * from './SonarCodeColorizer';
 export * from './SonarQubeLogo';
 export * from './Table';
 export * from './Tags';
@@ -77,6 +79,13 @@ export { ToggleButton } from './ToggleButton';
 export { TopBar } from './TopBar';
 export * from './buttons';
 export { ClipboardIconButton } from './clipboard';
+export * from './code-line/LineCoverage';
+export * from './code-line/LineIssuesIndicatorIcon';
+export * from './code-line/LineMarker';
+export * from './code-line/LineNumber';
+export * from './code-line/LineStyles';
+export * from './code-line/LineToken';
+export * from './code-line/LineWrapper';
 export * from './icons';
 export * from './layouts';
 export * from './modal/Modal';
index 785f6f07c8bc3ad43e5ab47c688bee0fec7eb1d3..81bb8e5dd6d6d43c929b4e2a18945095eee10b68 100644 (file)
@@ -133,4 +133,44 @@ export default {
     800: [49, 108, 146],
     900: [23, 67, 97],
   },
+  codeSnippetLight: {
+    body: [51, 53, 60],
+    annotations: [34, 84, 192],
+    constants: [126, 83, 5],
+    comments: [109, 111, 119],
+    keyword: [152, 29, 150],
+    string: [32, 105, 31],
+    'keyword-light': [28, 28, 163], // Not used currently in code snippet
+    'preprocessing-directive': [47, 103, 48],
+  },
+  codeSnippetDark: {
+    body: [241, 245, 253],
+    annotations: [137, 214, 255],
+    constants: [237, 182, 130],
+    comments: [156, 164, 175],
+    keyword: [251, 173, 255],
+    string: [177, 220, 146],
+    'keyword-light': [185, 185, 255], // Not used currently in code snippet
+    'preprocessing-directive': [133, 228, 134],
+  },
+  codeSyntaxLight: {
+    body: [56, 58, 66],
+    annotations: [35, 91, 213],
+    constants: [135, 87, 2],
+    comments: [95, 96, 102],
+    keyword: [162, 34, 160],
+    string: [36, 117, 35],
+    'keyword-light': [30, 30, 173],
+    'preprocessing-directive': [52, 114, 53],
+  },
+  codeSyntaxDark: {
+    body: [226, 231, 241],
+    annotations: [97, 174, 238],
+    constants: [209, 154, 102],
+    comments: [167, 172, 180],
+    keyword: [223, 145, 246],
+    string: [152, 195, 121],
+    'keyword-light': [171, 171, 255],
+    'preprocessing-directive': [120, 215, 121],
+  },
 };
index fb4ddc7a1d070348bc94c47e4eddcf8f927035df..dd154c0992194210381a1e1f0ed08e51a4f41fea 100644 (file)
@@ -220,6 +220,16 @@ export const lightTheme = {
     codeLineIssueLocationSelected: [...danger.lighter, 0.5],
     codeLineIssueMessageTooltip: secondary.darker,
 
+    // code syntax highlight
+    codeSyntaxBody: COLORS.codeSyntaxLight.body,
+    codeSyntaxAnnotations: COLORS.codeSyntaxLight.annotations,
+    codeSyntaxConstants: COLORS.codeSyntaxLight.constants,
+    codeSyntaxComments: COLORS.codeSyntaxLight.comments,
+    codeSyntaxKeyword: COLORS.codeSyntaxLight.keyword,
+    codeSyntaxString: COLORS.codeSyntaxLight.string,
+    codeSyntaxKeywordLight: COLORS.codeSyntaxLight['keyword-light'],
+    codeSyntaxPreprocessingDirective: COLORS.codeSyntaxLight['preprocessing-directive'],
+
     // checkbox
     checkboxHover: COLORS.indigo[50],
     checkboxCheckedHover: primary.light,
@@ -248,6 +258,7 @@ export const lightTheme = {
     // tooltip
     tooltipBackground: COLORS.blueGrey[600],
     tooltipSeparator: secondary.dark,
+    tooltipHighlight: secondary.default,
 
     // avatar
     avatarBackground: COLORS.white,
diff --git a/server/sonar-web/src/main/js/app/styles/sonar-colorizer.css b/server/sonar-web/src/main/js/app/styles/sonar-colorizer.css
deleted file mode 100644 (file)
index bc871a1..0000000
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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.
- */
-/* for example java annotations */
-.code .a {
-  color: #808000;
-}
-
-/* constants */
-.code .c {
-  color: #660e80;
-  font-style: normal;
-  font-weight: bold;
-}
-
-/* javadoc */
-.code .j {
-  color: #666666;
-  font-style: normal;
-}
-
-/* classic comment */
-.code .cd {
-  color: #666666;
-  font-style: italic;
-}
-
-/* C++ doc */
-.code .cppd {
-  color: #666666;
-  font-style: italic;
-}
-
-/* keyword */
-.code .k {
-  color: #0071ba;
-  font-weight: 600;
-}
-
-/* string */
-.code .s {
-  color: #277b31;
-  font-weight: normal;
-}
-
-/* keyword light*/
-.code .h {
-  color: #000080;
-  font-weight: normal;
-}
-
-/* preprocessing directive */
-.code .p {
-  color: #347235;
-  font-weight: normal;
-}
-
-.sym {
-  cursor: hand;
-  cursor: pointer;
-}
-
-.highlighted {
-  background-color: var(--lightBlue);
-  animation: highlightedFadeIn 0.3s forwards;
-}
-
-@keyframes highlightedFadeIn {
-  from {
-    background-color: transparent;
-  }
-
-  to {
-    background-color: var(--lightBlue);
-  }
-}
index ecaa397f6785680aa3c96ec2899e6218aca7b86b..c8f2b691709f0f9c14249cba304a551d838fd067 100644 (file)
@@ -46,5 +46,4 @@ import './init/tables.css';
 import './init/type.css';
 import './mixins.css';
 import './print.css';
-import './sonar-colorizer.css';
 import './style.css';
index 014471b2085480c974769c54c16c50c8046a60c4..59d2f52ade84eac04a2442f3519440c997bdabae 100644 (file)
@@ -19,7 +19,7 @@
  */
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { byRole, byText } from 'testing-library-selector';
+import { byRole } from 'testing-library-selector';
 import {
   componentsHandler,
   issuesHandler,
@@ -47,9 +47,7 @@ const ui = {
 
   scmInfoLine60: byRole('button', {
     name: 'source_viewer.author_X.simon.brandhof@sonarsource.com, source_viewer.click_for_scm_info.1',
-    expanded: false,
   }),
-  scmInfoExpanded: byText('80f564becc0c0a1c9abaa006eca83a4fd278c3f0'),
 };
 
 describe('issues source viewer', () => {
@@ -83,9 +81,9 @@ describe('issues source viewer', () => {
     expect(ui.line199.query()).not.toBeInTheDocument(); // Expand should only expand a few lines, not all of them
 
     // Show SCM info for newly expanded line
-    expect(ui.scmInfoExpanded.query()).not.toBeInTheDocument();
+    expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
     await user.click(ui.scmInfoLine60.get());
-    expect(ui.scmInfoExpanded.get()).toBeInTheDocument();
+    expect(screen.getByRole('tooltip')).toBeVisible();
   });
 
   it('should expand all lines', async () => {
index 93e16726fbc02f2e1a6cb2dcf87a6a423daf97e7..f6029dfa6396dddecaff4c707d5bdf64706f7b51 100644 (file)
@@ -62,7 +62,6 @@ interface Props {
   isLastOccurenceOfPrimaryComponent: boolean;
   issue: TypeIssue;
   issuesByLine: IssuesByLine;
-  lastSnippetGroup: boolean;
   loadDuplications: (component: string, line: SourceLine) => void;
   locations: FlowLocation[];
   onIssueSelect: (issueKey: string) => void;
@@ -256,8 +255,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
   };
 
   render() {
-    const { branchLike, isLastOccurenceOfPrimaryComponent, issue, lastSnippetGroup, snippetGroup } =
-      this.props;
+    const { branchLike, isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props;
     const { additionalLines, loading, snippets } = this.state;
 
     const snippetLines = linesForSnippets(snippets, {
@@ -320,7 +318,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
             </IssueSourceViewerScrollContext.Consumer>
           )}
 
-        {snippetLines.map((snippet, index) => (
+        {snippetLines.map(({ snippet, sourcesMap }, index) => (
           <SnippetViewer
             key={snippets[index].index}
             renderAdditionalChildInLine={this.renderIssuesList}
@@ -332,8 +330,6 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
             highlightedLocationMessage={this.props.highlightedLocationMessage}
             highlightedSymbols={this.state.highlightedSymbols}
             index={snippets[index].index}
-            issue={this.props.issue}
-            lastSnippetOfLastGroup={lastSnippetGroup && index === snippets.length - 1}
             loadDuplications={this.loadDuplications}
             locations={this.props.locations}
             locationsByLine={getLocationsByLine(
@@ -345,6 +341,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
             renderDuplicationPopup={this.renderDuplicationPopup}
             snippet={snippet}
             className={classNames({ 'sw-mt-2': index !== 0 })}
+            snippetSourcesMap={sourcesMap}
           />
         ))}
       </>
index d1ec05f8ba0f5a10f2e8c8fa6ebe70b5f237db89..8e5e8551a022c626e7b0b055bd4a672202604205 100644 (file)
@@ -21,6 +21,7 @@ import { findLastIndex, keyBy } from 'lodash';
 import * as React from 'react';
 import { getComponentForSourceViewer, getDuplications, getSources } from '../../../api/components';
 import { getIssueFlowSnippets } from '../../../api/issues';
+import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext';
 import DuplicationPopup from '../../../components/SourceViewer/components/DuplicationPopup';
 import {
   filterDuplicationBlocksByLine,
@@ -31,7 +32,6 @@ import {
   duplicationsByLine as getDuplicationsByLine,
   issuesByComponentAndLine,
 } from '../../../components/SourceViewer/helpers/indexing';
-import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext';
 import { Alert } from '../../../components/ui/Alert';
 import DeferredSpinner from '../../../components/ui/DeferredSpinner';
 import { WorkspaceContext } from '../../../components/workspace/context';
@@ -175,10 +175,11 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
           <DuplicationPopup
             blocks={filterDuplicationBlocksByLine(blocks, line)}
             branchLike={this.props.branchLike}
-            duplicatedFiles={duplicatedFiles}
             inRemovedComponent={isDuplicationBlockInRemovedComponent(blocks)}
+            duplicatedFiles={duplicatedFiles}
             openComponent={openComponent}
             sourceViewerFile={component}
+            duplicationHeader={translate('component_viewer.transition.duplication')}
           />
         )}
       </WorkspaceContext.Consumer>
@@ -234,7 +235,6 @@ export default class CrossComponentSourceViewer extends React.PureComponent<Prop
                 issue={issue}
                 issuesByLine={issuesByComponent[snippetGroup.component.key] || {}}
                 isLastOccurenceOfPrimaryComponent={i === lastOccurenceOfPrimaryComponent}
-                lastSnippetGroup={i === locationsByComponent.length - 1}
                 loadDuplications={this.fetchDuplications}
                 locations={snippetGroup.locations || []}
                 onIssueSelect={this.props.onIssueSelect}
index 7a0becf21bb9611bc4f186fadef797319cb4b9b2..b428953ca11a041d1087542349a7a8413a5ffabe 100644 (file)
 import classNames from 'classnames';
 import {
   CodeViewerExpander,
+  SonarCodeColorizer,
   ThemeProp,
   UnfoldDownIcon,
   UnfoldUpIcon,
   themeColor,
   withTheme,
 } from 'design-system';
-import * as React from 'react';
+import { debounce, throttle } from 'lodash';
+import React from 'react';
 import Line from '../../../components/SourceViewer/components/Line';
 import { symbolsByLine } from '../../../components/SourceViewer/helpers/indexing';
 import { getSecondaryIssueLocationsForLine } from '../../../components/SourceViewer/helpers/issueLocations';
@@ -39,13 +41,11 @@ import {
   Duplication,
   ExpandDirection,
   FlowLocation,
-  Issue,
+  LineMap,
   LinearIssueLocation,
   SourceLine,
   SourceViewerFile,
 } from '../../../types/types';
-import './SnippetViewer.css';
-import { LINES_BELOW_ISSUE } from './utils';
 
 export interface SnippetViewerProps {
   component: SourceViewerFile;
@@ -58,8 +58,6 @@ export interface SnippetViewerProps {
   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
   highlightedSymbols: string[];
   index: number;
-  issue: Pick<Issue, 'key' | 'textRange' | 'line'>;
-  lastSnippetOfLastGroup: boolean;
   loadDuplications?: (line: SourceLine) => void;
   locations: FlowLocation[];
   locationsByLine: { [line: number]: LinearIssueLocation[] };
@@ -68,154 +66,142 @@ export interface SnippetViewerProps {
   renderDuplicationPopup: (index: number, line: number) => React.ReactNode;
   snippet: SourceLine[];
   className?: string;
+  snippetSourcesMap?: LineMap;
 }
 
-class SnippetViewer extends React.PureComponent<SnippetViewerProps & ThemeProp> {
-  expandBlock = (direction: ExpandDirection) => () =>
-    this.props.expandBlock(this.props.index, direction);
+type Props = SnippetViewerProps & ThemeProp;
 
-  renderLine({
-    displayDuplications,
-    displaySCM,
-    index,
-    issueLocations,
-    line,
-    snippet,
-    symbols,
-    verticalBuffer,
-  }: {
-    displayDuplications: boolean;
-    displaySCM?: boolean;
-    index: number;
-    issueLocations: LinearIssueLocation[];
-    line: SourceLine;
-    snippet: SourceLine[];
-    symbols: string[];
-    verticalBuffer: number;
-  }) {
-    const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, this.props.locations);
+function SnippetViewer(props: Props) {
+  const expandBlock = (direction: ExpandDirection) => () => {
+    props.expandBlock(props.index, direction);
+  };
 
-    const { displayLineNumberOptions, duplications, duplicationsByLine } = this.props;
-    const duplicationsCount = duplications ? duplications.length : 0;
-    const lineDuplications =
-      (duplicationsCount && duplicationsByLine && duplicationsByLine[line.line]) || [];
+  const { component, displaySCM, locationsByLine, snippet, theme, className } = props;
 
-    const firstLineNumber = snippet && snippet.length ? snippet[0].line : 0;
-    const noop = () => {};
+  const { displayLineNumberOptions, duplications, duplicationsByLine, snippetSourcesMap } = props;
+  const duplicationsCount = duplications ? duplications.length : 0;
 
-    return (
-      <Line
-        displayCoverage={true}
-        displayDuplications={displayDuplications}
-        displayIssues={false}
-        displayLineNumberOptions={displayLineNumberOptions}
-        displayLocationMarkers={true}
-        displaySCM={displaySCM}
-        duplications={lineDuplications}
-        duplicationsCount={duplicationsCount}
-        firstLineNumber={firstLineNumber}
-        highlighted={false}
-        highlightedLocationMessage={optimizeLocationMessage(
-          this.props.highlightedLocationMessage,
-          secondaryIssueLocations
-        )}
-        highlightedSymbols={optimizeHighlightedSymbols(symbols, this.props.highlightedSymbols)}
-        issueLocations={issueLocations}
-        issues={[]}
-        key={line.line}
-        last={false}
-        line={line}
-        loadDuplications={this.props.loadDuplications || noop}
-        onIssueSelect={noop}
-        onIssueUnselect={noop}
-        onIssuesClose={noop}
-        onIssuesOpen={noop}
-        onLocationSelect={this.props.onLocationSelect}
-        onSymbolClick={this.props.handleSymbolClick}
-        openIssues={false}
-        previousLine={index > 0 ? snippet[index - 1] : undefined}
-        renderDuplicationPopup={this.props.renderDuplicationPopup}
-        secondaryIssueLocations={secondaryIssueLocations}
-        verticalBuffer={verticalBuffer}
-      >
-        {this.props.renderAdditionalChildInLine && this.props.renderAdditionalChildInLine(line)}
-      </Line>
-    );
-  }
+  const firstLineNumber = snippet?.length ? snippet[0].line : 0;
+  const noop = () => {
+    /* noop */
+  };
+  const lastLine = component.measures?.lines && parseInt(component.measures.lines, 10);
+
+  const symbols = symbolsByLine(snippet);
 
-  render() {
-    const {
-      component,
-      displaySCM,
-      issue,
-      lastSnippetOfLastGroup,
-      locationsByLine,
-      snippet,
-      theme,
-      className,
-    } = this.props;
-    const lastLine =
-      component.measures && component.measures.lines && parseInt(component.measures.lines, 10);
+  const displayDuplications =
+    Boolean(props.loadDuplications) && snippet.some((s) => !!s.duplicated);
 
-    const symbols = symbolsByLine(snippet);
+  const borderColor = themeColor('codeLineBorder')({ theme });
 
-    const bottomLine = snippet[snippet.length - 1].line;
-    const issueLine = issue.textRange ? issue.textRange.endLine : issue.line;
+  const THROTTLE_SHORT_DELAY = 10;
+  const [hoveredLine, setHoveredLine] = React.useState<SourceLine | undefined>();
 
-    const verticalBuffer =
-      lastSnippetOfLastGroup && issueLine
-        ? Math.max(0, LINES_BELOW_ISSUE - (bottomLine - issueLine))
-        : 0;
+  const onLineMouseEnter = React.useMemo(
+    () =>
+      throttle(
+        (hoveredLine: number) =>
+          snippetSourcesMap ? setHoveredLine(snippetSourcesMap[hoveredLine]) : undefined,
+        THROTTLE_SHORT_DELAY
+      ),
+    [snippetSourcesMap]
+  );
 
-    const displayDuplications =
-      Boolean(this.props.loadDuplications) && snippet.some((s) => !!s.duplicated);
+  const onLineMouseLeave = React.useMemo(
+    () =>
+      debounce(
+        (line: number) =>
+          setHoveredLine((hoveredLine) => (hoveredLine?.line === line ? undefined : hoveredLine)),
+        THROTTLE_SHORT_DELAY
+      ),
+    []
+  );
 
-    const borderColor = themeColor('codeLineBorder')({ theme });
+  return (
+    <div
+      className={classNames('it__source-viewer-code', className)}
+      style={{ border: `1px solid ${borderColor}` }}
+    >
+      <SonarCodeColorizer>
+        {snippet[0].line > 1 && (
+          <CodeViewerExpander
+            direction="UP"
+            className="sw-flex sw-justify-start sw-items-center sw-py-1 sw-px-2"
+            onClick={expandBlock('up')}
+          >
+            <UnfoldUpIcon aria-label={translate('source_viewer.expand_above')} />
+          </CodeViewerExpander>
+        )}
+        <table className="sw-w-full">
+          <tbody>
+            {snippet.map((line, index) => {
+              const secondaryIssueLocations = getSecondaryIssueLocationsForLine(
+                line,
+                props.locations
+              );
+              const lineDuplications =
+                (duplicationsCount && duplicationsByLine && duplicationsByLine[line.line]) || [];
 
-    return (
-      <div
-        className={classNames('source-viewer-code', className)}
-        style={{ border: `1px solid ${borderColor}` }}
-      >
-        <div>
-          {snippet[0].line > 1 && (
-            <CodeViewerExpander
-              direction="UP"
-              className="sw-flex sw-justify-start sw-items-center sw-py-1 sw-px-2"
-              onClick={this.expandBlock('up')}
-            >
-              <UnfoldUpIcon aria-label={translate('source_viewer.expand_above')} />
-            </CodeViewerExpander>
-          )}
-          <table>
-            <tbody>
-              {snippet.map((line, index) =>
-                this.renderLine({
-                  displayDuplications,
-                  displaySCM,
-                  index,
-                  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) && (
-            <CodeViewerExpander
-              className="sw-flex sw-justify-start sw-items-center sw-py-1 sw-px-2"
-              onClick={this.expandBlock('down')}
-              direction="DOWN"
-            >
-              <UnfoldDownIcon aria-label={translate('source_viewer.expand_below')} />
-            </CodeViewerExpander>
-          )}
-        </div>
-      </div>
-    );
-  }
+              const displayCoverageUnderline = hoveredLine?.coverageBlock === line.coverageBlock;
+              const displayNewCodeUnderline = hoveredLine?.newCodeBlock === line.line;
+              return (
+                <Line
+                  displayCoverage={true}
+                  displayCoverageUnderline={displayCoverageUnderline}
+                  displayNewCodeUnderline={displayNewCodeUnderline}
+                  displayDuplications={displayDuplications}
+                  displayIssues={false}
+                  displayLineNumberOptions={displayLineNumberOptions}
+                  displayLocationMarkers={true}
+                  displaySCM={displaySCM}
+                  duplications={lineDuplications}
+                  duplicationsCount={duplicationsCount}
+                  firstLineNumber={firstLineNumber}
+                  highlighted={false}
+                  highlightedLocationMessage={optimizeLocationMessage(
+                    props.highlightedLocationMessage,
+                    secondaryIssueLocations
+                  )}
+                  highlightedSymbols={optimizeHighlightedSymbols(
+                    symbols[line.line],
+                    props.highlightedSymbols
+                  )}
+                  issueLocations={locationsByLine[line.line] || []}
+                  issues={[]}
+                  key={line.line}
+                  line={line}
+                  loadDuplications={props.loadDuplications ?? noop}
+                  onIssueSelect={noop}
+                  onIssueUnselect={noop}
+                  onIssuesClose={noop}
+                  onIssuesOpen={noop}
+                  onLocationSelect={props.onLocationSelect}
+                  onSymbolClick={props.handleSymbolClick}
+                  openIssues={false}
+                  previousLine={index > 0 ? snippet[index - 1] : undefined}
+                  renderDuplicationPopup={props.renderDuplicationPopup}
+                  secondaryIssueLocations={secondaryIssueLocations}
+                  onLineMouseEnter={onLineMouseEnter}
+                  onLineMouseLeave={onLineMouseLeave}
+                >
+                  {props.renderAdditionalChildInLine?.(line)}
+                </Line>
+              );
+            })}
+          </tbody>
+        </table>
+        {(!lastLine || snippet[snippet.length - 1].line < lastLine) && (
+          <CodeViewerExpander
+            className="sw-flex sw-justify-start sw-items-center sw-py-1 sw-px-2"
+            onClick={expandBlock('down')}
+            direction="DOWN"
+          >
+            <UnfoldDownIcon aria-label={translate('source_viewer.expand_below')} />
+          </CodeViewerExpander>
+        )}
+      </SonarCodeColorizer>
+    </div>
+  );
 }
 
 export default withTheme(SnippetViewer);
index cdbcdb84b88872390520e2ca184f928dc577d1ae..df138d37b6744530cd9630dae0246aa0c4ca7ca4 100644 (file)
@@ -22,7 +22,6 @@ import { range } from 'lodash';
 import * as React from 'react';
 import { byRole } from 'testing-library-selector';
 import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/mocks/sources';
-import { mockIssue } from '../../../../helpers/testMocks';
 import { renderComponent } from '../../../../helpers/testReactTestingUtils';
 import SnippetViewer, { SnippetViewerProps } from '../SnippetViewer';
 
@@ -102,8 +101,6 @@ function renderSnippetViewer(props: Partial<SnippetViewerProps> = {}) {
       highlightedLocationMessage={{ index: 0, text: '' }}
       highlightedSymbols={[]}
       index={0}
-      issue={mockIssue()}
-      lastSnippetOfLastGroup={false}
       loadDuplications={jest.fn()}
       locations={[]}
       locationsByLine={{}}
index 10287fb3267709478a218d18587be9e1e7882fdf..f84d5db25b0edf1dfbb73f1691b3ac6bcf69e1e0 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { isDefined } from '../../../helpers/types';
 import { ComponentQualifier } from '../../../types/component';
 import {
   ExpandDirection,
@@ -134,18 +135,36 @@ export function createSnippets(params: {
   return hasSecondaryLocations ? ranges.sort((a, b) => a.start - b.start) : ranges;
 }
 
+function decorateWithUnderlineFlags(line: SourceLine, sourcesMap: LineMap) {
+  const previousLine = sourcesMap[line.line - 1];
+  const decoratedLine = { ...line };
+  if (isDefined(line.coverageStatus)) {
+    decoratedLine.coverageBlock =
+      line.coverageStatus === previousLine?.coverageStatus ? previousLine.coverageBlock : line.line;
+  }
+  if (line.isNew) {
+    decoratedLine.newCodeBlock = previousLine?.isNew ? previousLine.newCodeBlock : line.line;
+  }
+  return decoratedLine;
+}
+
 export function linesForSnippets(snippets: Snippet[], componentLines: LineMap) {
-  return snippets
-    .map((snippet) => {
-      const lines = [];
-      for (let i = snippet.start; i <= snippet.end; i++) {
-        if (componentLines[i]) {
-          lines.push(componentLines[i]);
-        }
+  return snippets.reduce<Array<{ snippet: SourceLine[]; sourcesMap: LineMap }>>((acc, snippet) => {
+    const snippetSources = [];
+    const snippetSourcesMap: LineMap = {};
+    for (let idx = snippet.start; idx <= snippet.end; idx++) {
+      if (isDefined(componentLines[idx])) {
+        const line = decorateWithUnderlineFlags(componentLines[idx], snippetSourcesMap);
+        snippetSourcesMap[line.line] = line;
+        snippetSources.push(line);
       }
-      return lines;
-    })
-    .filter((snippet) => snippet.length > 0);
+    }
+
+    if (snippetSources.length > 0) {
+      acc.push({ snippet: snippetSources, sourcesMap: snippetSourcesMap });
+    }
+    return acc;
+  }, []);
 }
 
 export function groupLocationsByComponent(
index 8f09fcc0e4bf22920db2844533b2e28114150048..086527df353321417aa47bb81b27843badf70367 100644 (file)
@@ -302,7 +302,7 @@ describe('navigation', () => {
     expect(ui.fixContent.get()).toBeInTheDocument();
 
     await user.click(ui.codeTab.get());
-    expect(ui.codeContent.get()).toHaveClass('source-table');
+    expect(ui.codeContent.get()).toBeInTheDocument();
   });
 
   it('should be able to navigate the hotspot list with keyboard', async () => {
index 78a329c7dddf6daeaa4f62c9f6ff96baefdfe018..3434874b0d7ad3b9c6c7ceb3ca82802de78ac818 100644 (file)
@@ -169,8 +169,6 @@ export default function HotspotSnippetContainerRenderer(
             highlightedLocationMessage={highlightedLocation}
             highlightedSymbols={highlightedSymbols}
             index={0}
-            issue={hotspot}
-            lastSnippetOfLastGroup={false}
             locations={secondaryLocations}
             locationsByLine={primaryLocations}
             onLocationSelect={props.onLocationSelect}
index e47851519808424847db180a81f18c9fd4777dd5..3f813f76da8539b18819f428d4a391b71cd74dc8 100644 (file)
@@ -42,6 +42,9 @@ import {
 } from '../../types/types';
 import { Alert } from '../ui/Alert';
 import { WorkspaceContext } from '../workspace/context';
+import SourceViewerCode from './SourceViewerCode';
+import { SourceViewerContext } from './SourceViewerContext';
+import SourceViewerHeader from './SourceViewerHeader';
 import DuplicationPopup from './components/DuplicationPopup';
 import {
   filterDuplicationBlocksByLine,
@@ -57,9 +60,6 @@ import {
 } from './helpers/indexing';
 import { LINES_TO_LOAD } from './helpers/lines';
 import loadIssues from './helpers/loadIssues';
-import SourceViewerCode from './SourceViewerCode';
-import { SourceViewerContext } from './SourceViewerContext';
-import SourceViewerHeader from './SourceViewerHeader';
 import './styles.css';
 
 export interface Props {
@@ -487,10 +487,11 @@ export default class SourceViewer extends React.PureComponent<Props, State> {
           <DuplicationPopup
             blocks={filterDuplicationBlocksByLine(blocks, line)}
             branchLike={this.props.branchLike}
-            duplicatedFiles={duplicatedFiles}
             inRemovedComponent={isDuplicationBlockInRemovedComponent(blocks)}
+            duplicatedFiles={duplicatedFiles}
             openComponent={openComponent}
             sourceViewerFile={component}
+            duplicationHeader={translate('component_viewer.transition.duplication')}
           />
         )}
       </WorkspaceContext.Consumer>
index f2a24ae873ba9504b5ca6674e2d656fd667a87b5..7d473ae4de041a5b9969bac9ffba5524e118aff8 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { SonarCodeColorizer } from 'design-system/lib';
+import { noop } from 'lodash';
 import * as React from 'react';
 import { Button } from '../../components/controls/buttons';
 import { translate } from '../../helpers/l10n';
@@ -152,6 +154,10 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
     return (
       <Line
         displayAllIssues={this.props.displayAllIssues}
+        displayNewCodeUnderline={false}
+        displayCoverageUnderline={false}
+        onLineMouseEnter={noop}
+        onLineMouseLeave={noop}
         displayCoverage={displayCoverage}
         displayDuplications={displayDuplications}
         displayIssues={displayIssues}
@@ -172,7 +178,6 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
         issueLocations={this.getIssueLocationsForLine(line)}
         issues={issuesForLine}
         key={line.line || line.code}
-        last={index === this.props.sources.length - 1 && !this.props.hasSourcesAfter}
         line={line}
         loadDuplications={this.props.loadDuplications}
         onIssueSelect={this.props.onIssueSelect}
@@ -217,63 +222,71 @@ export default class SourceViewerCode extends React.PureComponent<Props> {
     const hasFileIssues = displayIssues && issues.some((issue) => !issue.textRange);
 
     return (
-      <div className="source-viewer-code">
-        {this.props.hasSourcesBefore && (
-          <div className="source-viewer-more-code">
-            {this.props.loadingSourcesBefore ? (
-              <div className="js-component-viewer-loading-before">
-                <i className="spinner" />
-                <span className="note spacer-left">
-                  {translate('source_viewer.loading_more_code')}
-                </span>
-              </div>
-            ) : (
-              <Button
-                className="js-component-viewer-source-before"
-                onClick={this.props.loadSourcesBefore}
-              >
-                {translate('source_viewer.load_more_code')}
-              </Button>
-            )}
-          </div>
-        )}
+      <SonarCodeColorizer>
+        <div className="it__source-viewer-code">
+          {this.props.hasSourcesBefore && (
+            <div className="source-viewer-more-code">
+              {this.props.loadingSourcesBefore ? (
+                <div className="js-component-viewer-loading-before">
+                  <i className="spinner" />
+                  <span className="note spacer-left">
+                    {translate('source_viewer.loading_more_code')}
+                  </span>
+                </div>
+              ) : (
+                <Button
+                  className="js-component-viewer-source-before"
+                  onClick={this.props.loadSourcesBefore}
+                >
+                  {translate('source_viewer.load_more_code')}
+                </Button>
+              )}
+            </div>
+          )}
 
-        <table className="source-table">
-          <tbody>
-            {hasFileIssues &&
-              this.renderLine({
-                line: ZERO_LINE,
-                index: -1,
-                displayCoverage,
-                displayDuplications,
-                displayIssues,
-              })}
-            {sources.map((line, index) =>
-              this.renderLine({ line, index, displayCoverage, displayDuplications, displayIssues })
-            )}
-          </tbody>
-        </table>
+          <table className="source-table">
+            <tbody>
+              {hasFileIssues &&
+                this.renderLine({
+                  line: ZERO_LINE,
+                  index: -1,
+                  displayCoverage,
+                  displayDuplications,
+                  displayIssues,
+                })}
+              {sources.map((line, index) =>
+                this.renderLine({
+                  line,
+                  index,
+                  displayCoverage,
+                  displayDuplications,
+                  displayIssues,
+                })
+              )}
+            </tbody>
+          </table>
 
-        {this.props.hasSourcesAfter && (
-          <div className="source-viewer-more-code">
-            {this.props.loadingSourcesAfter ? (
-              <div className="js-component-viewer-loading-after">
-                <i className="spinner" />
-                <span className="note spacer-left">
-                  {translate('source_viewer.loading_more_code')}
-                </span>
-              </div>
-            ) : (
-              <Button
-                className="js-component-viewer-source-after"
-                onClick={this.props.loadSourcesAfter}
-              >
-                {translate('source_viewer.load_more_code')}
-              </Button>
-            )}
-          </div>
-        )}
-      </div>
+          {this.props.hasSourcesAfter && (
+            <div className="source-viewer-more-code">
+              {this.props.loadingSourcesAfter ? (
+                <div className="js-component-viewer-loading-after">
+                  <i className="spinner" />
+                  <span className="note spacer-left">
+                    {translate('source_viewer.loading_more_code')}
+                  </span>
+                </div>
+              ) : (
+                <Button
+                  className="js-component-viewer-source-after"
+                  onClick={this.props.loadSourcesAfter}
+                >
+                  {translate('source_viewer.load_more_code')}
+                </Button>
+              )}
+            </div>
+          )}
+        </div>
+      </SonarCodeColorizer>
     );
   }
 }
index 541ab1cfcb374697a03d2901c04ab2ac3c37fc45..865ed68ea6a4dffd97f67b958f971e2ff8a5d945 100644 (file)
@@ -30,3 +30,7 @@ export const SourceViewerContext = React.createContext<SourceViewerContextShape>
   branchLike: {} as BranchLike,
   file: {} as SourceViewerFile,
 });
+
+export function useSourceViewerContext() {
+  return React.useContext(SourceViewerContext);
+}
index 1439f083790ed4c96196c2b9d89d7cccc5440dbb..4fe7c105ac29428336dcc6d2a60e616a5bc788e9 100644 (file)
@@ -27,8 +27,8 @@ import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock';
 import { HttpStatus } from '../../../helpers/request';
 import { mockIssue } from '../../../helpers/testMocks';
 import { renderComponent } from '../../../helpers/testReactTestingUtils';
-import loadIssues from '../helpers/loadIssues';
 import SourceViewer from '../SourceViewer';
+import loadIssues from '../helpers/loadIssues';
 
 jest.mock('../../../api/components');
 jest.mock('../../../api/issues');
@@ -110,8 +110,8 @@ it('should show a permalink on line number', async () => {
   });
 
   expect(
-    lowerRowScreen.getByRole('button', {
-      name: 'component_viewer.copy_permalink',
+    lowerRowScreen.getByRole('menuitem', {
+      name: 'source_viewer.copy_permalink',
     })
   ).toBeInTheDocument();
 });
@@ -204,15 +204,10 @@ it('should show SCM information', async () => {
     })
   );
 
-  expect(
-    await firstRowScreen.findByRole('heading', { level: 4, name: 'author' })
-  ).toBeInTheDocument();
-  expect(
-    firstRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.commited_on' })
-  ).toBeInTheDocument();
-  expect(
-    firstRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.revision' })
-  ).toBeInTheDocument();
+  // After using miui component the tooltip is appearing outside of the row
+  expect(await screen.findAllByText('author')).toHaveLength(4);
+  expect(screen.getAllByText('source_viewer.tooltip.scm.commited_on')).toHaveLength(3);
+  expect(screen.getAllByText('source_viewer.tooltip.scm.revision')).toHaveLength(7);
 
   row = screen.getByRole('row', { name: /\* SonarQube$/ });
   expect(row).toBeInTheDocument();
@@ -225,48 +220,26 @@ it('should show SCM information', async () => {
   row = await screen.findByRole('row', { name: /\* mailto:info AT sonarsource DOT com$/ });
   expect(row).toBeInTheDocument();
   const fourthRowScreen = within(row);
-  await user.click(
-    fourthRowScreen.getByRole('button', {
-      name: 'source_viewer.author_X.stas.vilchik@sonarsource.com, source_viewer.click_for_scm_info.4',
-    })
-  );
-
-  expect(
-    await fourthRowScreen.findByRole('heading', { level: 4, name: 'author' })
-  ).toBeInTheDocument();
-  expect(
-    fourthRowScreen.queryByRole('heading', {
-      level: 4,
-      name: 'source_viewer.tooltip.scm.commited_on',
-    })
-  ).not.toBeInTheDocument();
-  expect(
-    fourthRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.revision' })
-  ).toBeInTheDocument();
+  await act(async () => {
+    await user.click(
+      fourthRowScreen.getByRole('button', {
+        name: 'source_viewer.author_X.stas.vilchik@sonarsource.com, source_viewer.click_for_scm_info.4',
+      })
+    );
+  });
 
   // SCM with no date no author
   row = await screen.findByRole('row', { name: /\* 5$/ });
   expect(row).toBeInTheDocument();
   const fithRowScreen = within(row);
   expect(fithRowScreen.getByText('…')).toBeInTheDocument();
-  await user.click(
-    fithRowScreen.getByRole('button', {
-      name: 'source_viewer.click_for_scm_info.5',
-    })
-  );
-
-  expect(
-    fithRowScreen.queryByRole('heading', { level: 4, name: 'author' })
-  ).not.toBeInTheDocument();
-  expect(
-    fithRowScreen.queryByRole('heading', {
-      level: 4,
-      name: 'source_viewer.tooltip.scm.commited_on',
-    })
-  ).not.toBeInTheDocument();
-  expect(
-    fithRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.revision' })
-  ).toBeInTheDocument();
+  await act(async () => {
+    await user.click(
+      fithRowScreen.getByRole('button', {
+        name: 'source_viewer.click_for_scm_info.5',
+      })
+    );
+  });
 
   // No SCM Popup
   row = await screen.findByRole('row', {
@@ -369,16 +342,19 @@ it('should show duplication block', async () => {
     duplicateLine.getByLabelText('source_viewer.tooltip.duplicated_block')
   ).toBeInTheDocument();
 
-  await user.click(
-    duplicateLine.getByRole('button', { name: 'source_viewer.tooltip.duplicated_block' })
-  );
+  await act(async () => {
+    await user.click(
+      duplicateLine.getByRole('button', { name: 'source_viewer.tooltip.duplicated_block' })
+    );
+  });
 
-  expect(duplicateLine.getAllByRole('link', { name: 'foo:test2.js' })[0]).toBeInTheDocument();
+  expect(screen.getByRole('tooltip')).toBeVisible();
 
   await act(async () => {
-    await user.keyboard('[Escape]');
+    await user.click(document.body);
   });
-  expect(duplicateLine.queryByRole('link', { name: 'foo:test2.js' })).not.toBeInTheDocument();
+
+  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
 });
 
 it('should highlight symbol', async () => {
index d69d6418d0541232ea7c969683d8584d00c01735..ca1c33e6722d01ac17a687014655d6cbe5c06119 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 {
+  DiscreetLink,
+  DuplicationHighlight,
+  FlagMessage,
+  StandoutLink as Link,
+  QualifierIcon,
+} from 'design-system';
 import { groupBy, sortBy } from 'lodash';
-import * as React from 'react';
-import Link from '../../../components/common/Link';
-import QualifierIcon from '../../../components/icons/QualifierIcon';
-import { Alert } from '../../../components/ui/Alert';
+import React, { Fragment, PureComponent } from 'react';
 import { isPullRequest } from '../../../helpers/branch-like';
 import { translate } from '../../../helpers/l10n';
 import { collapsedDirFromPath, fileFromPath } from '../../../helpers/path';
 import { getProjectUrl } from '../../../helpers/urls';
 import { BranchLike } from '../../../types/branch-like';
+import { ComponentQualifier } from '../../../types/component';
 import { Dict, DuplicatedFile, DuplicationBlock, SourceViewerFile } from '../../../types/types';
 import { WorkspaceContextShape } from '../../workspace/context';
 
@@ -35,11 +40,12 @@ interface Props {
   branchLike: BranchLike | undefined;
   duplicatedFiles?: Dict<DuplicatedFile>;
   inRemovedComponent: boolean;
+  duplicationHeader: string;
   openComponent: WorkspaceContextShape['openComponent'];
   sourceViewerFile: SourceViewerFile;
 }
 
-export default class DuplicationPopup extends React.PureComponent<Props> {
+export default class DuplicationPopup extends PureComponent<Props> {
   shouldLink() {
     const { branchLike } = this.props;
     return !isPullRequest(branchLike);
@@ -64,22 +70,27 @@ export default class DuplicationPopup extends React.PureComponent<Props> {
 
   renderDuplication(file: DuplicatedFile, children: React.ReactNode, line?: number) {
     return this.shouldLink() ? (
-      <a
+      <DiscreetLink
         data-key={file.key}
         data-line={line}
-        href="#"
         onClick={this.handleFileClick}
         title={file.name}
+        to={{}}
       >
         {children}
-      </a>
+      </DiscreetLink>
     ) : (
       children
     );
   }
 
   render() {
-    const { duplicatedFiles = {}, sourceViewerFile } = this.props;
+    const {
+      duplicatedFiles = {},
+      sourceViewerFile,
+      duplicationHeader,
+      inRemovedComponent,
+    } = this.props;
 
     const groupedBlocks = groupBy(this.props.blocks, '_ref');
     let duplications = Object.keys(groupedBlocks).map((fileRef) => {
@@ -90,7 +101,6 @@ export default class DuplicationPopup extends React.PureComponent<Props> {
     });
 
     // first duplications in the same file
-    // then duplications in the same sub-project
     // then duplications in the same project
     // then duplications in other projects
     duplications = sortBy(
@@ -100,23 +110,24 @@ export default class DuplicationPopup extends React.PureComponent<Props> {
     );
 
     return (
-      <div className="source-viewer-bubble-popup abs-width-400">
-        {this.props.inRemovedComponent && (
-          <Alert variant="warning">
+      <div className="sw-w-abs-400">
+        {inRemovedComponent && (
+          <FlagMessage
+            ariaLabel={translate('duplications.dups_found_on_deleted_resource')}
+            variant="warning"
+          >
             {translate('duplications.dups_found_on_deleted_resource')}
-          </Alert>
+          </FlagMessage>
         )}
         {duplications.length > 0 && (
           <>
-            <h6 className="spacer-bottom">
-              {translate('component_viewer.transition.duplication')}
-            </h6>
+            <DuplicationHighlight>{duplicationHeader}</DuplicationHighlight>
             {duplications.map((duplication) => (
-              <div className="spacer-top text-ellipsis" key={duplication.file.key}>
-                <div className="component-name">
+              <div className="sw-my-2 sw-truncate" key={duplication.file.key}>
+                <div className="sw-flex sw-flex-wrap sw-body-sm">
                   {this.isDifferentComponent(duplication.file, this.props.sourceViewerFile) && (
-                    <div className="component-name-parent">
-                      <QualifierIcon className="little-spacer-right" qualifier="TRK" />
+                    <div className="sw-mr-4">
+                      <QualifierIcon className="sw-mr-1" qualifier={ComponentQualifier.Project} />
                       <Link to={getProjectUrl(duplication.file.project)}>
                         {duplication.file.projectName}
                       </Link>
@@ -124,23 +135,21 @@ export default class DuplicationPopup extends React.PureComponent<Props> {
                   )}
 
                   {duplication.file.key !== this.props.sourceViewerFile.key && (
-                    <div className="component-name-path">
+                    <div className="sw-mr-2">
                       {this.renderDuplication(
                         duplication.file,
                         <>
                           <span>{collapsedDirFromPath(duplication.file.name)}</span>
-                          <span className="component-name-file">
-                            {fileFromPath(duplication.file.name)}
-                          </span>
+                          <span>{fileFromPath(duplication.file.name)}</span>
                         </>
                       )}
                     </div>
                   )}
 
-                  <div className="component-name-path">
+                  <div>
                     {'Lines: '}
                     {duplication.blocks.map((block, index) => (
-                      <React.Fragment key={index}>
+                      <Fragment key={index}>
                         {this.renderDuplication(
                           duplication.file,
                           <>
@@ -151,7 +160,7 @@ export default class DuplicationPopup extends React.PureComponent<Props> {
                           block.from
                         )}
                         {index < duplication.blocks.length - 1 && ', '}
-                      </React.Fragment>
+                      </Fragment>
                     ))}
                   </div>
                 </div>
index 7d3d48fd7be50f5bd7d1129a4331d77d10ed8ff0..e804c11b9df8b0374939c2c98f4c8a994762f2d4 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import classNames from 'classnames';
+import { LineCoverage, LineMeta, LineNumber, LineWrapper } from 'design-system';
 import { times } from 'lodash';
 import * as React from 'react';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { getCodeUrl, getPathUrlAsString } from '../../../helpers/urls';
 import { Issue, LinearIssueLocation, SourceLine } from '../../../types/types';
+import { useSourceViewerContext } from '../SourceViewerContext';
 import './Line.css';
-import LineCode from './LineCode';
-import LineCoverage from './LineCoverage';
+import { LineCode } from './LineCode';
 import LineDuplicationBlock from './LineDuplicationBlock';
 import LineIssuesIndicator from './LineIssuesIndicator';
-import LineNumber from './LineNumber';
+import LineOptionsPopup from './LineOptionsPopup';
 import LineSCM from './LineSCM';
 
-interface Props {
+export interface LineProps {
   children?: React.ReactNode;
   displayAllIssues?: boolean;
   displayCoverage: boolean;
@@ -46,7 +49,6 @@ interface Props {
   highlightedSymbols: string[] | undefined;
   issueLocations: LinearIssueLocation[];
   issues: Issue[];
-  last: boolean;
   line: SourceLine;
   loadDuplications: (line: SourceLine) => void;
   onIssuesClose: (line: SourceLine) => void;
@@ -60,127 +62,182 @@ interface Props {
   renderDuplicationPopup: (index: number, line: number) => React.ReactNode;
   scrollToUncoveredLine?: boolean;
   secondaryIssueLocations: LinearIssueLocation[];
-  verticalBuffer?: number;
+  onLineMouseEnter: (line: number) => void;
+  onLineMouseLeave: (line: number) => void;
+  displayCoverageUnderline: boolean;
+  displayNewCodeUnderline: boolean;
 }
 
-const LINE_HEIGHT = 18;
+export default function Line(props: LineProps) {
+  const {
+    children,
+    displayAllIssues,
+    displayCoverage,
+    displayDuplications,
+    displayLineNumberOptions,
+    displayLocationMarkers,
+    highlightedLocationMessage,
+    displayNewCodeUnderline,
+    displayIssues,
+    displaySCM = true,
+    duplications,
+    duplicationsCount,
+    firstLineNumber,
+    highlighted,
+    highlightedSymbols,
+    issueLocations,
+    issues,
+    line,
+    openIssues,
+    previousLine,
+    scrollToUncoveredLine,
+    secondaryIssueLocations,
+    displayCoverageUnderline,
+    onLineMouseEnter,
+    onLineMouseLeave,
+  } = props;
 
-export default class Line extends React.PureComponent<Props> {
-  handleIssuesIndicatorClick = () => {
-    if (this.props.openIssues) {
-      this.props.onIssuesClose(this.props.line);
-      this.props.onIssueUnselect();
+  const handleIssuesIndicatorClick = () => {
+    if (props.openIssues) {
+      props.onIssuesClose(props.line);
+      props.onIssueUnselect();
     } else {
-      this.props.onIssuesOpen(this.props.line);
+      props.onIssuesOpen(props.line);
 
-      const { issues } = this.props;
+      const { issues } = props;
       if (issues.length > 0) {
-        this.props.onIssueSelect(issues[0].key);
+        props.onIssueSelect(issues[0].key);
       }
     }
   };
 
-  render() {
-    const {
-      children,
-      displayAllIssues,
-      displayCoverage,
-      displayDuplications,
-      displayLineNumberOptions,
-      displayLocationMarkers,
-      highlightedLocationMessage,
-      displayIssues,
-      displaySCM = true,
-      duplications,
-      duplicationsCount,
-      firstLineNumber,
-      highlighted,
-      highlightedSymbols,
-      issueLocations,
-      issues,
-      last,
-      line,
-      openIssues,
-      previousLine,
-      scrollToUncoveredLine,
-      secondaryIssueLocations,
-      verticalBuffer,
-    } = this.props;
-
-    const className = classNames('source-line', {
-      'source-line-highlighted': highlighted,
-      'source-line-filtered': line.isNew,
-      'source-line-filtered-dark':
-        displayCoverage &&
-        (line.coverageStatus === 'uncovered' || line.coverageStatus === 'partially-covered'),
-      'source-line-last': last === true,
-    });
-
-    const bottomPadding = verticalBuffer ? verticalBuffer * LINE_HEIGHT : undefined;
-    const blocksLoaded = duplicationsCount > 0;
-
-    // default is true
-    const displayOptions = displayLineNumberOptions !== false;
-    return (
-      <tr className={className} data-line-number={line.line}>
-        <LineNumber displayOptions={displayOptions} firstLineNumber={firstLineNumber} line={line} />
-
-        {displaySCM && <LineSCM line={line} previousLine={previousLine} />}
-        {displayIssues && !displayAllIssues ? (
-          <LineIssuesIndicator
-            issues={issues}
-            issuesOpen={openIssues}
-            line={line}
-            onClick={this.handleIssuesIndicatorClick}
-          />
-        ) : (
-          <td className="source-meta source-line-issues" />
-        )}
-
-        {displayDuplications && (
-          <LineDuplicationBlock
-            blocksLoaded={blocksLoaded}
-            duplicated={!blocksLoaded ? Boolean(line.duplicated) : duplications.includes(0)}
-            index={0}
-            key={0}
-            line={this.props.line}
-            onClick={this.props.loadDuplications}
-            renderDuplicationPopup={this.props.renderDuplicationPopup}
-          />
-        )}
-
-        {blocksLoaded &&
-          times(duplicationsCount - 1, (index) => {
-            return (
-              <LineDuplicationBlock
-                blocksLoaded={blocksLoaded}
-                duplicated={duplications.includes(index + 1)}
-                index={index + 1}
-                key={index + 1}
-                line={this.props.line}
-                renderDuplicationPopup={this.props.renderDuplicationPopup}
-              />
-            );
-          })}
-
-        {displayCoverage && (
-          <LineCoverage line={line} scrollToUncoveredLine={scrollToUncoveredLine} />
-        )}
-
-        <LineCode
-          displayLocationMarkers={displayLocationMarkers}
-          highlightedLocationMessage={highlightedLocationMessage}
-          highlightedSymbols={highlightedSymbols}
-          issueLocations={issueLocations}
+  const blocksLoaded = duplicationsCount > 0;
+
+  const handleLineMouseEnter = React.useCallback(
+    () => onLineMouseEnter(line.line),
+    [line.line, onLineMouseEnter]
+  );
+
+  const handleLineMouseLeave = React.useCallback(
+    () => onLineMouseLeave(line.line),
+    [line.line, onLineMouseLeave]
+  );
+
+  // default is true
+  const displayOptions = displayLineNumberOptions !== false;
+
+  const { branchLike, file } = useSourceViewerContext();
+  const permalink = getPathUrlAsString(
+    getCodeUrl(file.project, branchLike, file.key, line.line),
+    false
+  );
+
+  const getStatusTooltip = (line: SourceLine) => {
+    switch (line.coverageStatus) {
+      case 'uncovered':
+        return line.conditions
+          ? translateWithParameters('source_viewer.tooltip.uncovered.conditions', line.conditions)
+          : translate('source_viewer.tooltip.uncovered');
+      case 'covered':
+        return line.conditions
+          ? translateWithParameters('source_viewer.tooltip.covered.conditions', line.conditions)
+          : translate('source_viewer.tooltip.covered');
+      case 'partially-covered':
+        return line.conditions
+          ? translateWithParameters(
+              'source_viewer.tooltip.partially-covered.conditions',
+              line.coveredConditions ?? 0,
+              line.conditions
+            )
+          : translate('source_viewer.tooltip.partially-covered');
+      default:
+        return undefined;
+    }
+  };
+
+  const status = getStatusTooltip(line);
+
+  return (
+    <LineWrapper
+      data-line-number={line.line}
+      displayCoverage={displayCoverage}
+      displaySCM={displaySCM}
+      duplicationsCount={!duplicationsCount && displayDuplications ? 1 : duplicationsCount}
+      highlighted={highlighted}
+      onMouseEnter={handleLineMouseEnter}
+      onMouseLeave={handleLineMouseLeave}
+      className={classNames('it__source-line', { 'it__source-line-filtered': line.isNew })}
+    >
+      <LineNumber
+        displayOptions={displayOptions}
+        firstLineNumber={firstLineNumber}
+        lineNumber={line.line}
+        ariaLabel={translateWithParameters('source_viewer.line_X', line.line)}
+        popup={<LineOptionsPopup line={line} permalink={permalink} />}
+      />
+
+      {displaySCM && <LineSCM line={line} previousLine={previousLine} />}
+      {displayIssues && !displayAllIssues ? (
+        <LineIssuesIndicator
+          issues={issues}
+          issuesOpen={openIssues}
           line={line}
-          onLocationSelect={this.props.onLocationSelect}
-          onSymbolClick={this.props.onSymbolClick}
-          padding={bottomPadding}
-          secondaryIssueLocations={secondaryIssueLocations}
-        >
-          {children}
-        </LineCode>
-      </tr>
-    );
-  }
+          onClick={handleIssuesIndicatorClick}
+        />
+      ) : (
+        <LineMeta data-line-number={line.line} />
+      )}
+
+      {displayDuplications && (
+        <LineDuplicationBlock
+          blocksLoaded={blocksLoaded}
+          duplicated={!blocksLoaded ? Boolean(line.duplicated) : duplications.includes(0)}
+          index={0}
+          key={0}
+          line={line}
+          onClick={props.loadDuplications}
+          renderDuplicationPopup={props.renderDuplicationPopup}
+        />
+      )}
+
+      {blocksLoaded &&
+        times(duplicationsCount - 1, (index) => {
+          return (
+            <LineDuplicationBlock
+              blocksLoaded={blocksLoaded}
+              duplicated={duplications.includes(index + 1)}
+              index={index + 1}
+              key={index + 1}
+              line={line}
+              renderDuplicationPopup={props.renderDuplicationPopup}
+            />
+          );
+        })}
+
+      {displayCoverage && (
+        <LineCoverage
+          lineNumber={line.line}
+          scrollToUncoveredLine={scrollToUncoveredLine}
+          status={status}
+          coverageStatus={line.coverageStatus}
+        />
+      )}
+      <LineCode
+        displayCoverageUnderline={displayCoverage && displayCoverageUnderline}
+        displayLocationMarkers={displayLocationMarkers}
+        displayNewCodeUnderlineLabel={displayNewCodeUnderline}
+        hideLocationIndex={false}
+        highlightedLocationMessage={highlightedLocationMessage}
+        highlightedSymbols={highlightedSymbols}
+        issueLocations={issueLocations}
+        line={line}
+        onLocationSelect={props.onLocationSelect}
+        onSymbolClick={props.onSymbolClick}
+        previousLine={previousLine}
+        secondaryIssueLocations={secondaryIssueLocations}
+      >
+        {children}
+      </LineCode>
+    </LineWrapper>
+  );
 }
index 87745243401c7770667c1f19581d42a95c728a4e..1976957c122402020e222b2109fb3f0a3e75b1c3 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 classNames from 'classnames';
-import * as React from 'react';
+import {
+  CoveredUnderline,
+  CoveredUnderlineLabel,
+  LineCodeLayer,
+  LineCodeLayers,
+  LineCodePreFormatted,
+  LineMarker,
+  LineToken,
+  NewCodeUnderline,
+  NewCodeUnderlineLabel,
+  UncoveredUnderline,
+  UncoveredUnderlineLabel,
+  UnderlineLabels,
+} from 'design-system';
+import React, { PureComponent, ReactNode } from 'react';
 import { IssueSourceViewerScrollContext } from '../../../apps/issues/components/IssueSourceViewerScrollContext';
-import { MessageFormatting } from '../../../types/issues';
+import { translate } from '../../../helpers/l10n';
 import { LinearIssueLocation, SourceLine } from '../../../types/types';
-import LocationIndex from '../../common/LocationIndex';
-import Tooltip from '../../controls/Tooltip';
-import { IssueMessageHighlighting } from '../../issue/IssueMessageHighlighting';
-import {
-  highlightIssueLocations,
-  highlightSymbol,
-  splitByTokens,
-  Token,
-} from '../helpers/highlight';
+import { Token, getHighlightedTokens } from '../helpers/highlight';
 
 interface Props {
-  className?: string;
+  displayCoverageUnderline?: boolean;
   displayLocationMarkers?: boolean;
+  displayNewCodeUnderlineLabel?: boolean;
+  hideLocationIndex?: boolean;
   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
   highlightedSymbols: string[] | undefined;
   issueLocations: LinearIssueLocation[];
   line: SourceLine;
   onLocationSelect: ((index: number) => void) | undefined;
-  onSymbolClick: (symbols: Array<string>) => void;
-  padding?: number;
+  onSymbolClick: (symbols: string[]) => void;
+  previousLine?: SourceLine;
   secondaryIssueLocations: LinearIssueLocation[];
 }
 
-export default class LineCode extends React.PureComponent<React.PropsWithChildren<Props>> {
+export class LineCode extends PureComponent<React.PropsWithChildren<Props>> {
   symbols?: NodeListOf<HTMLElement>;
 
   nodeNodeRef = (el: HTMLElement | null) => {
@@ -83,9 +90,46 @@ export default class LineCode extends React.PureComponent<React.PropsWithChildre
     }
   };
 
-  renderToken(tokens: Token[]) {
-    const { highlightedLocationMessage, secondaryIssueLocations } = this.props;
-    const renderedTokens: React.ReactNode[] = [];
+  addLineMarker = (marker: number, index: number, leadingMarker: boolean, markerIndex: number) => {
+    const { highlightedLocationMessage, secondaryIssueLocations, hideLocationIndex } = this.props;
+    const selected =
+      highlightedLocationMessage !== undefined && highlightedLocationMessage.index === marker;
+    const loc = secondaryIssueLocations.find((loc) => loc.index === marker);
+    const message = loc?.text;
+    const isLeading = leadingMarker && markerIndex === 0;
+    return (
+      <IssueSourceViewerScrollContext.Consumer>
+        {(ctx) => (
+          <LineMarker
+            hideLocationIndex={hideLocationIndex}
+            index={marker}
+            key={`${marker}-${index}`}
+            leading={isLeading}
+            message={message}
+            onLocationSelect={this.props.onLocationSelect}
+            ref={selected ? ctx?.registerSelectedSecondaryLocationRef : undefined}
+            selected={selected}
+          />
+        )}
+      </IssueSourceViewerScrollContext.Consumer>
+    );
+  };
+
+  addLineToken = (token: Token, index: number) => {
+    return (
+      <LineToken
+        className={token.className}
+        hasMarker={token.markers.length > 0}
+        key={`${token.text}-${index}`}
+        {...token.modifiers}
+      >
+        {token.text}
+      </LineToken>
+    );
+  };
+
+  renderTokens = (tokens: Token[]) => {
+    const renderedTokens: ReactNode[] = [];
 
     // track if the first marker is displayed before the source code
     // set `false` for the first token in a row
@@ -93,122 +137,87 @@ export default class LineCode extends React.PureComponent<React.PropsWithChildre
 
     tokens.forEach((token, index) => {
       if (this.props.displayLocationMarkers && token.markers.length > 0) {
-        token.markers.forEach((marker) => {
-          const selected =
-            highlightedLocationMessage !== undefined && highlightedLocationMessage.index === marker;
-          const loc = secondaryIssueLocations.find((loc) => loc.index === marker);
-          const message = loc?.text;
-          const messageFormattings = loc?.textFormatting;
-          renderedTokens.push(
-            this.renderMarker(marker, message, messageFormattings, selected, leadingMarker)
-          );
+        token.markers.forEach((marker, markerIndex) => {
+          renderedTokens.push(this.addLineMarker(marker, index, leadingMarker, markerIndex));
         });
       }
-      renderedTokens.push(
-        // eslint-disable-next-line react/no-array-index-key
-        <span className={token.className} key={index}>
-          {token.text}
-        </span>
-      );
+
+      renderedTokens.push(this.addLineToken(token, index));
 
       // keep leadingMarker truthy if previous token has only whitespaces
       leadingMarker = (index === 0 ? true : leadingMarker) && !token.text.trim().length;
     });
-    return renderedTokens;
-  }
-
-  renderMarker(
-    index: number,
-    message: string | undefined,
-    messageFormattings: MessageFormatting[] | undefined,
-    selected: boolean,
-    leading: boolean
-  ) {
-    const { onLocationSelect } = this.props;
-    const onClick = onLocationSelect ? () => onLocationSelect(index) : undefined;
 
-    return (
-      <Tooltip
-        key={`marker-${index}`}
-        overlay={
-          <IssueMessageHighlighting message={message} messageFormattings={messageFormattings} />
-        }
-        placement="top"
-      >
-        <LocationIndex
-          leading={leading}
-          onClick={onClick}
-          selected={selected}
-          aria-current={selected ? 'location' : false}
-        >
-          <IssueSourceViewerScrollContext.Consumer>
-            {(ctx) => (
-              <span ref={selected ? ctx?.registerSelectedSecondaryLocationRef : undefined}>
-                {index + 1}
-              </span>
-            )}
-          </IssueSourceViewerScrollContext.Consumer>
-        </LocationIndex>
-      </Tooltip>
-    );
-  }
+    return renderedTokens;
+  };
 
   render() {
     const {
+      displayCoverageUnderline,
+      displayNewCodeUnderlineLabel,
       children,
-      className,
       highlightedLocationMessage,
       highlightedSymbols,
       issueLocations,
       line,
-      padding,
+      previousLine,
       secondaryIssueLocations,
     } = this.props;
 
-    const container = document.createElement('div');
-    container.innerHTML = this.props.line.code || '';
-
-    let tokens = splitByTokens(container.childNodes);
-
-    if (highlightedSymbols) {
-      highlightedSymbols.forEach((symbol) => {
-        tokens = highlightSymbol(tokens, symbol);
-      });
-    }
-
-    if (issueLocations.length > 0) {
-      tokens = highlightIssueLocations(tokens, issueLocations);
-    }
-
-    if (secondaryIssueLocations) {
-      tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'issue-location');
-
-      if (highlightedLocationMessage) {
-        const location = secondaryIssueLocations.find(
-          (location) => location.index === highlightedLocationMessage.index
-        );
-        if (location) {
-          tokens = highlightIssueLocations(tokens, [location], 'selected');
-        }
-      }
-    }
-
-    const renderedTokens = this.renderToken(tokens);
-
-    const style = padding ? { paddingBottom: `${padding}px` } : undefined;
+    const displayCoverageUnderlineLabel =
+      displayCoverageUnderline && line.coverageBlock === line.line;
+    const previousLineHasUnderline =
+      previousLine?.isNew ||
+      (previousLine?.coverageStatus && previousLine.coverageBlock === line.coverageBlock);
 
     return (
-      <td
-        className={classNames('source-line-code code', className)}
+      <LineCodeLayers
+        className="js-source-line-code it__source-line-code"
         data-line-number={line.line}
-        style={style}
       >
-        <div className="source-line-code-inner">
-          <pre ref={this.nodeNodeRef}>{renderedTokens}</pre>
-        </div>
-
-        {children}
-      </td>
+        {(displayCoverageUnderlineLabel || displayNewCodeUnderlineLabel) && (
+          <UnderlineLabels aria-hidden={true} transparentBackground={previousLineHasUnderline}>
+            {displayCoverageUnderlineLabel && line.coverageStatus === 'covered' && (
+              <CoveredUnderlineLabel>
+                {translate('source_viewer.coverage.covered')}
+              </CoveredUnderlineLabel>
+            )}
+            {displayCoverageUnderlineLabel &&
+              (line.coverageStatus === 'uncovered' ||
+                line.coverageStatus === 'partially-covered') && (
+                <UncoveredUnderlineLabel>
+                  {translate('source_viewer.coverage', line.coverageStatus)}
+                </UncoveredUnderlineLabel>
+              )}
+            {displayNewCodeUnderlineLabel && (
+              <NewCodeUnderlineLabel>{translate('source_viewer.new_code')}</NewCodeUnderlineLabel>
+            )}
+          </UnderlineLabels>
+        )}
+        {line.isNew && <NewCodeUnderline aria-hidden={true} data-testid="new-code-underline" />}
+        {displayCoverageUnderline && line.coverageStatus === 'covered' && (
+          <CoveredUnderline aria-hidden={true} data-testid="covered-underline" />
+        )}
+        {displayCoverageUnderline &&
+          (line.coverageStatus === 'uncovered' || line.coverageStatus === 'partially-covered') && (
+            <UncoveredUnderline aria-hidden={true} data-testid="uncovered-underline" />
+          )}
+
+        <LineCodeLayer className="sw-px-3">
+          <LineCodePreFormatted ref={this.nodeNodeRef}>
+            {this.renderTokens(
+              getHighlightedTokens({
+                code: line.code,
+                highlightedLocationMessage,
+                highlightedSymbols,
+                issueLocations,
+                secondaryIssueLocations,
+              })
+            )}
+          </LineCodePreFormatted>
+          {children}
+        </LineCodeLayer>
+      </LineCodeLayers>
     );
   }
 }
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx
deleted file mode 100644 (file)
index 55bf4b7..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 Tooltip from '../../../components/controls/Tooltip';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { SourceLine } from '../../../types/types';
-
-export interface LineCoverageProps {
-  line: SourceLine;
-  scrollToUncoveredLine?: boolean;
-}
-
-export function LineCoverage({ line, scrollToUncoveredLine }: LineCoverageProps) {
-  const coverageMarker = React.useRef<HTMLTableCellElement>(null);
-  React.useEffect(() => {
-    if (scrollToUncoveredLine && coverageMarker.current) {
-      coverageMarker.current.scrollIntoView({
-        behavior: 'smooth',
-        block: 'center',
-        inline: 'center',
-      });
-    }
-  }, [scrollToUncoveredLine, coverageMarker]);
-
-  const className =
-    'source-meta source-line-coverage' +
-    (line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : '');
-  const status = getStatusTooltip(line);
-
-  return (
-    <td className={className} data-line-number={line.line} ref={coverageMarker}>
-      <Tooltip overlay={status} placement="bottom">
-        <div aria-label={status} role="img" className="source-line-bar" />
-      </Tooltip>
-    </td>
-  );
-}
-
-function getStatusTooltip(line: SourceLine) {
-  switch (line.coverageStatus) {
-    case 'uncovered':
-      return line.conditions
-        ? translateWithParameters('source_viewer.tooltip.uncovered.conditions', line.conditions)
-        : translate('source_viewer.tooltip.uncovered');
-    case 'covered':
-      return line.conditions
-        ? translateWithParameters('source_viewer.tooltip.covered.conditions', line.conditions)
-        : translate('source_viewer.tooltip.covered');
-    case 'partially-covered':
-      return line.conditions
-        ? translateWithParameters(
-            'source_viewer.tooltip.partially-covered.conditions',
-            line.coveredConditions || 0,
-            line.conditions
-          )
-        : translate('source_viewer.tooltip.partially-covered');
-    default:
-      return undefined;
-  }
-}
-
-export default React.memo(LineCoverage);
index 86d2fde8b8cbb848cbaa27c800b5821989e365da..da2c306f5ab9b64bd6ce9cfabc4e13e5bb73137f 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 classNames from 'classnames';
+import { DuplicationBlock, LineMeta, OutsideClickHandler, PopupPlacement } from 'design-system';
 import * as React from 'react';
-import { DropdownOverlay } from '../../../components/controls/Dropdown';
-import Toggler from '../../../components/controls/Toggler';
 import Tooltip from '../../../components/controls/Tooltip';
-import { PopupPlacement } from '../../../components/ui/popups';
 import { translate } from '../../../helpers/l10n';
 import { SourceLine } from '../../../types/types';
-import { ButtonPlain } from '../../controls/buttons';
 
 export interface LineDuplicationBlockProps {
   blocksLoaded: boolean;
@@ -37,46 +33,49 @@ export interface LineDuplicationBlockProps {
 }
 
 export function LineDuplicationBlock(props: LineDuplicationBlockProps) {
-  const { blocksLoaded, duplicated, index, line } = props;
-  const [dropdownOpen, setDropdownOpen] = React.useState(false);
+  const { blocksLoaded, duplicated, index, line, onClick } = props;
+  const [popupOpen, setPopupOpen] = React.useState(false);
 
-  const className = classNames('source-meta', 'source-line-duplications', {
-    'source-line-duplicated': duplicated,
-  });
+  const tooltip = popupOpen ? undefined : translate('source_viewer.tooltip.duplicated_block');
 
-  const tooltip = dropdownOpen ? undefined : translate('source_viewer.tooltip.duplicated_block');
+  const handleClick = React.useCallback(() => {
+    setPopupOpen(!popupOpen);
+    if (!blocksLoaded && line.duplicated && onClick) {
+      onClick(line);
+    }
+  }, [blocksLoaded, line, onClick, popupOpen]);
+
+  const handleClose = React.useCallback(() => setPopupOpen(false), []);
 
   return duplicated ? (
-    <td className={className} data-index={index} data-line-number={line.line}>
-      <Tooltip overlay={tooltip} placement="right">
-        <div>
-          <Toggler
-            onRequestClose={() => setDropdownOpen(false)}
-            open={dropdownOpen}
-            overlay={
-              <DropdownOverlay placement={PopupPlacement.RightTop}>
-                {props.renderDuplicationPopup(index, line.line)}
-              </DropdownOverlay>
-            }
+    <Tooltip overlay={tooltip} placement={PopupPlacement.Right}>
+      <LineMeta
+        className="it__source-line-duplicated"
+        data-index={index}
+        data-line-number={line.line}
+      >
+        <OutsideClickHandler onClickOutside={handleClose}>
+          <Tooltip
+            placement={PopupPlacement.Right}
+            visible={popupOpen}
+            isInteractive={true}
+            overlay={popupOpen ? props.renderDuplicationPopup(index, line.line) : undefined}
+            classNameInner="sw-max-w-abs-400"
           >
-            <ButtonPlain
+            <DuplicationBlock
               aria-label={translate('source_viewer.tooltip.duplicated_block')}
-              className="source-line-bar"
-              onClick={() => {
-                setDropdownOpen(true);
-                if (!blocksLoaded && line.duplicated && props.onClick) {
-                  props.onClick(line);
-                }
-              }}
+              aria-expanded={popupOpen}
+              aria-haspopup="dialog"
+              onClick={handleClick}
+              role="button"
+              tabIndex={0}
             />
-          </Toggler>
-        </div>
-      </Tooltip>
-    </td>
+          </Tooltip>
+        </OutsideClickHandler>
+      </LineMeta>
+    </Tooltip>
   ) : (
-    <td className={className} data-index={index} data-line-number={line.line}>
-      <div className="source-line-bar" />
-    </td>
+    <LineMeta data-index={index} data-line-number={line.line} />
   );
 }
 
index f2cf3b718b6721209ac65a094cf1098f8f7b36ab..6a6bdcfaa0acbbe55f581083fb4f796b534fceb0 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 classNames from 'classnames';
+import { IssueIndicatorButton, LineIssuesIndicatorIcon, LineMeta } from 'design-system';
 import { uniq } from 'lodash';
 import * as React from 'react';
 import Tooltip from '../../../components/controls/Tooltip';
-import IssueIcon from '../../../components/icons/IssueIcon';
 import { sortByType } from '../../../helpers/issues';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Issue, SourceLine } from '../../../types/types';
-import { ButtonPlain } from '../../controls/buttons';
+
+const MOUSE_LEAVE_DELAY = 0.25;
 
 export interface LineIssuesIndicatorProps {
   issues: Issue[];
@@ -37,12 +37,9 @@ export interface LineIssuesIndicatorProps {
 export function LineIssuesIndicator(props: LineIssuesIndicatorProps) {
   const { issues, issuesOpen, line } = props;
   const hasIssues = issues.length > 0;
-  const className = classNames('source-meta', 'source-line-issues', {
-    'source-line-with-issues': hasIssues,
-  });
 
   if (!hasIssues) {
-    return <td className={className} data-line-number={line.line} />;
+    return <LineMeta />;
   }
 
   const mostImportantIssue = sortByType(issues)[0];
@@ -71,14 +68,20 @@ export function LineIssuesIndicator(props: LineIssuesIndicatorProps) {
   }
 
   return (
-    <td className={className} data-line-number={line.line}>
-      <Tooltip overlay={tooltipContent}>
-        <ButtonPlain aria-label={tooltipContent} aria-expanded={issuesOpen} onClick={props.onClick}>
-          <IssueIcon type={mostImportantIssue.type} />
-          {issues.length > 1 && <span className="source-line-issues-counter">{issues.length}</span>}
-        </ButtonPlain>
+    <LineMeta className="it__source-line-with-issues" data-line-number={line.line}>
+      <Tooltip mouseLeaveDelay={MOUSE_LEAVE_DELAY} overlay={tooltipContent}>
+        <IssueIndicatorButton
+          aria-label={tooltipContent}
+          aria-expanded={issuesOpen}
+          onClick={props.onClick}
+        >
+          <LineIssuesIndicatorIcon
+            issuesCount={issues.length}
+            mostImportantIssueType={mostImportantIssue.type}
+          />
+        </IssueIndicatorButton>
       </Tooltip>
-    </td>
+    </LineMeta>
   );
 }
 
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx
deleted file mode 100644 (file)
index eb1e4d9..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 Toggler from '../../../components/controls/Toggler';
-import { translateWithParameters } from '../../../helpers/l10n';
-import { SourceLine } from '../../../types/types';
-import { ButtonPlain } from '../../controls/buttons';
-import LineOptionsPopup from './LineOptionsPopup';
-
-export interface LineNumberProps {
-  displayOptions: boolean;
-  firstLineNumber: number;
-  line: SourceLine;
-}
-
-export function LineNumber({ displayOptions, firstLineNumber, line }: LineNumberProps) {
-  const [isOpen, setOpen] = React.useState<boolean>(false);
-  const { line: lineNumber } = line;
-  const hasLineNumber = !!lineNumber;
-
-  return hasLineNumber ? (
-    <td className="source-meta source-line-number" data-line-number={lineNumber}>
-      {displayOptions ? (
-        <Toggler
-          closeOnClickOutside={true}
-          onRequestClose={() => setOpen(false)}
-          open={isOpen}
-          overlay={<LineOptionsPopup firstLineNumber={firstLineNumber} line={line} />}
-        >
-          <ButtonPlain
-            aria-expanded={isOpen}
-            aria-haspopup={true}
-            aria-label={translateWithParameters('source_viewer.line_X', lineNumber)}
-            onClick={() => setOpen(true)}
-          >
-            {lineNumber}
-          </ButtonPlain>
-        </Toggler>
-      ) : (
-        lineNumber
-      )}
-    </td>
-  ) : (
-    <td className="source-meta source-line-number" />
-  );
-}
-
-export default React.memo(LineNumber);
index e3c79711441902c30a54ab2c21337fdd6d682525..a647b8b0c5a955446d5c1df0895bb46a2ee546c3 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 * as React from 'react';
-import { DropdownOverlay } from '../../../components/controls/Dropdown';
-import { PopupPlacement } from '../../../components/ui/popups';
+import { DropdownMenu, ItemCopy } from 'design-system';
+import React, { memo } from 'react';
 import { translate } from '../../../helpers/l10n';
-import { getCodeUrl, getPathUrlAsString } from '../../../helpers/urls';
 import { SourceLine } from '../../../types/types';
-import { ClipboardButton } from '../../controls/clipboard';
-import { SourceViewerContext } from '../SourceViewerContext';
+import { getLineCodeAsPlainText } from '../helpers/lines';
 
-export interface LineOptionsPopupProps {
-  firstLineNumber: number;
+interface Props {
   line: SourceLine;
+  permalink: string;
 }
 
-export function LineOptionsPopup({ firstLineNumber, line }: LineOptionsPopupProps) {
+export function LineOptionsPopup({ line, permalink }: Props) {
+  const lineCodeAsPlainText = getLineCodeAsPlainText(line.code);
   return (
-    <SourceViewerContext.Consumer>
-      {({ branchLike, file }) => {
-        const codeLocation = getCodeUrl(file.project, branchLike, file.key, line.line);
-        const codeUrl = getPathUrlAsString(codeLocation, false);
-        const isAtTop = line.line - 4 < firstLineNumber;
-        return (
-          <DropdownOverlay
-            className="big-spacer-left"
-            noPadding={true}
-            placement={isAtTop ? PopupPlacement.BottomLeft : PopupPlacement.TopLeft}
-          >
-            <div className="padded source-viewer-bubble-popup nowrap">
-              <ClipboardButton
-                className="button-link"
-                copyValue={codeUrl}
-                aria-label={translate('component_viewer.copy_permalink')}
-              >
-                {translate('component_viewer.copy_permalink')}
-              </ClipboardButton>
-            </div>
-          </DropdownOverlay>
-        );
-      }}
-    </SourceViewerContext.Consumer>
+    <DropdownMenu>
+      <ItemCopy
+        copyValue={permalink}
+        tooltipOverlay={translate('source_viewer.copied_to_clipboard')}
+      >
+        {translate('source_viewer.copy_permalink')}
+      </ItemCopy>
+
+      {lineCodeAsPlainText && (
+        <ItemCopy
+          copyValue={lineCodeAsPlainText}
+          tooltipOverlay={translate('source_viewer.copied_to_clipboard')}
+        >
+          {translate('source_viewer.copy_line')}
+        </ItemCopy>
+      )}
+    </DropdownMenu>
   );
 }
 
-export default React.memo(LineOptionsPopup);
+export default memo(LineOptionsPopup);
index b9e31b605ad9199e93c80e1c5031ca947f1e5409..5dd9ec882debbfdfd0388af448cd2114a5f5733a 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 * as React from 'react';
-import Dropdown from '../../../components/controls/Dropdown';
-import { PopupPlacement } from '../../../components/ui/popups';
+import {
+  LineMeta,
+  LineSCMStyled,
+  LineSCMStyledDiv,
+  OutsideClickHandler,
+  PopupPlacement,
+} from 'design-system';
+import React, { memo, useCallback, useState } from 'react';
 import { translateWithParameters } from '../../../helpers/l10n';
 import { SourceLine } from '../../../types/types';
-import { ButtonPlain } from '../../controls/buttons';
+import Tooltip from '../../controls/Tooltip';
 import SCMPopup from './SCMPopup';
 
-export interface LineSCMProps {
+interface Props {
   line: SourceLine;
   previousLine: SourceLine | undefined;
 }
 
-export function LineSCM({ line, previousLine }: LineSCMProps) {
-  const hasPopup = !!line.line;
-  const cell = (
-    <div className="source-line-scm-inner">
-      {isSCMChanged(line, previousLine) ? line.scmAuthor || '…' : ' '}
-    </div>
-  );
+function LineSCM({ line, previousLine }: Props) {
+  const [isOpen, setIsOpen] = useState(false);
 
-  if (hasPopup) {
-    let ariaLabel = translateWithParameters('source_viewer.click_for_scm_info', line.line);
-    if (line.scmAuthor) {
-      ariaLabel = `${translateWithParameters(
-        'source_viewer.author_X',
-        line.scmAuthor
-      )}, ${ariaLabel}`;
-    }
+  const handleToggle = useCallback(() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+  const handleClose = useCallback(() => {
+    setIsOpen(false);
+  }, []);
 
+  const isFileIssue = !line.line;
+  if (isFileIssue) {
     return (
-      <td className="source-meta source-line-scm" data-line-number={line.line}>
-        <Dropdown overlay={<SCMPopup line={line} />} overlayPlacement={PopupPlacement.RightTop}>
-          <ButtonPlain aria-label={ariaLabel}>{cell}</ButtonPlain>
-        </Dropdown>
-      </td>
+      <LineMeta>
+        <LineSCMStyledDiv>{line.scmAuthor ?? ' '}</LineSCMStyledDiv>
+      </LineMeta>
     );
   }
+
+  let ariaLabel = translateWithParameters('source_viewer.click_for_scm_info', line.line);
+  if (line.scmAuthor) {
+    ariaLabel = `${translateWithParameters(
+      'source_viewer.author_X',
+      line.scmAuthor
+    )}, ${ariaLabel}`;
+  }
+
   return (
-    <td className="source-meta source-line-scm" data-line-number={line.line}>
-      {cell}
-    </td>
+    <LineMeta data-line-number={line.line}>
+      <OutsideClickHandler onClickOutside={handleClose}>
+        <Tooltip
+          overlay={<SCMPopup line={line} />}
+          placement={PopupPlacement.Right}
+          visible={isOpen}
+          isInteractive={true}
+          classNameInner="sw-max-w-abs-600"
+        >
+          <LineSCMStyled aria-label={ariaLabel} onClick={handleToggle} role="button">
+            {isSCMChanged(line, previousLine) ? line.scmAuthor ?? '…' : ' '}
+          </LineSCMStyled>
+        </Tooltip>
+      </OutsideClickHandler>
+    </LineMeta>
   );
 }
 
@@ -70,4 +89,4 @@ function isSCMChanged(s: SourceLine, p: SourceLine | undefined) {
   return changed;
 }
 
-export default React.memo(LineSCM);
+export default memo(LineSCM);
index cd2f934bd834534f52060fbf82cbae5c86171cdd..5d1272abc2e78a8c6a0e6e5e81da3268fb87a15c 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 classNames from 'classnames';
-import * as React from 'react';
+import { SCMHighlight } from 'design-system';
+import React, { memo } from 'react';
 import { translate } from '../../../helpers/l10n';
 import { SourceLine } from '../../../types/types';
 import DateFormatter from '../../intl/DateFormatter';
 
-export interface SCMPopupProps {
+interface Props {
   line: SourceLine;
 }
 
-export function SCMPopup({ line }: SCMPopupProps) {
+export function SCMPopup({ line }: Props) {
   const hasAuthor = line.scmAuthor !== undefined && line.scmAuthor !== '';
   const hasDate = line.scmDate !== undefined;
   return (
-    <div className="source-viewer-bubble-popup abs-width-400">
+    <div className="sw-select-text sw-text-left">
       {hasAuthor && (
-        <div>
-          <h4>{translate('author')}</h4>
-          {line.scmAuthor}
+        <div className="sw-flex sw-items-center">
+          <SCMHighlight>{translate('author')}</SCMHighlight>
+          <div className="sw-whitespace-nowrap sw-mr-2">{line.scmAuthor}</div>
         </div>
       )}
       {hasDate && (
-        <div className={classNames({ 'spacer-top': hasAuthor })}>
-          <h4>{translate('source_viewer.tooltip.scm.commited_on')}</h4>
-          <DateFormatter date={line.scmDate!} />
+        <div className="sw-flex sw-items-center">
+          <SCMHighlight>{translate('source_viewer.tooltip.scm.commited_on')}</SCMHighlight>
+          <div className="sw-whitespace-nowrap sw-mr-2">
+            <DateFormatter date={line.scmDate!} />
+          </div>
         </div>
       )}
       {line.scmRevision && (
-        <div className={classNames({ 'spacer-top': hasAuthor || hasDate })}>
-          <h4>{translate('source_viewer.tooltip.scm.revision')}</h4>
-          {line.scmRevision}
+        <div className="sw-flex sw-items-center">
+          <SCMHighlight>{translate('source_viewer.tooltip.scm.revision')}</SCMHighlight>
+          <div className="sw-whitespace-nowrap sw-mr-2">{line.scmRevision}</div>
         </div>
       )}
     </div>
   );
 }
 
-export default React.memo(SCMPopup);
+export default memo(SCMPopup);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx
deleted file mode 100644 (file)
index 9941ae5..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockSourceLine } from '../../../../helpers/mocks/sources';
-import { mockIssue } from '../../../../helpers/testMocks';
-import Line from '../Line';
-
-it('should render correctly for last, new, and highlighted lines', () => {
-  expect(
-    shallowRender({
-      highlighted: true,
-      last: true,
-      line: mockSourceLine({ isNew: true }),
-    })
-  ).toMatchSnapshot();
-});
-
-it('handles the opening and closing of issues', () => {
-  const line = mockSourceLine();
-  const issue = mockIssue();
-  const onIssuesClose = jest.fn();
-  const onIssueUnselect = jest.fn();
-  const onIssuesOpen = jest.fn();
-  const onIssueSelect = jest.fn();
-  const wrapper = shallowRender({
-    issues: [issue],
-    line,
-    onIssuesClose,
-    onIssueSelect,
-    onIssuesOpen,
-    onIssueUnselect,
-    openIssues: true,
-  });
-  const instance = wrapper.instance();
-
-  instance.handleIssuesIndicatorClick();
-  expect(onIssuesClose).toHaveBeenCalledWith(line);
-  expect(onIssueUnselect).toHaveBeenCalled();
-
-  wrapper.setProps({ openIssues: false });
-  instance.handleIssuesIndicatorClick();
-  expect(onIssuesOpen).toHaveBeenCalledWith(line);
-  expect(onIssueSelect).toHaveBeenCalledWith(issue.key);
-});
-
-function shallowRender(props: Partial<Line['props']> = {}) {
-  return shallow<Line>(
-    <Line
-      displayAllIssues={false}
-      displayCoverage={false}
-      displayDuplications={false}
-      displayIssues={false}
-      displayLocationMarkers={false}
-      duplications={[0]}
-      duplicationsCount={0}
-      firstLineNumber={1}
-      highlighted={false}
-      highlightedLocationMessage={undefined}
-      highlightedSymbols={undefined}
-      issueLocations={[]}
-      issues={[mockIssue(), mockIssue(false, { type: 'VULNERABILITY' })]}
-      last={false}
-      line={mockSourceLine()}
-      loadDuplications={jest.fn()}
-      onIssuesClose={jest.fn()}
-      onIssueSelect={jest.fn()}
-      onIssuesOpen={jest.fn()}
-      onIssueUnselect={jest.fn()}
-      onLocationSelect={jest.fn()}
-      onSymbolClick={jest.fn()}
-      openIssues={false}
-      previousLine={undefined}
-      renderDuplicationPopup={jest.fn()}
-      secondaryIssueLocations={[]}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx
deleted file mode 100644 (file)
index 53fa5ed..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockSourceLine } from '../../../../helpers/mocks/sources';
-import LineCode from '../LineCode';
-
-it('render code', () => {
-  expect(shallowRender()).toMatchSnapshot();
-  expect(shallowRender({ children: <div>additional child</div> })).toMatchSnapshot(
-    'with additional child'
-  );
-  expect(
-    shallowRender({
-      secondaryIssueLocations: [
-        { index: 1, from: 5, to: 6, line: 16, startLine: 16, text: 'secondary-location-msg' },
-      ],
-    })
-  ).toMatchSnapshot('with secondary location');
-});
-
-function shallowRender(props: Partial<LineCode['props']> = {}) {
-  return shallow(
-    <LineCode
-      displayLocationMarkers={true}
-      highlightedLocationMessage={{ index: 0, text: 'location description' }}
-      highlightedSymbols={['sym-9']}
-      issueLocations={[{ from: 0, to: 5, line: 16 }]}
-      line={mockSourceLine()}
-      onLocationSelect={jest.fn()}
-      onSymbolClick={jest.fn()}
-      secondaryIssueLocations={[]}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx
deleted file mode 100644 (file)
index e687e93..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { LineCoverage, LineCoverageProps } from '../LineCoverage';
-
-jest.mock('react', () => {
-  return {
-    ...jest.requireActual('react'),
-    useRef: jest.fn(),
-    useEffect: jest.fn(),
-  };
-});
-
-it('should correctly trigger a scroll', () => {
-  const scroll = jest.fn();
-  const element = { current: { scrollIntoView: scroll } };
-  (React.useEffect as jest.Mock).mockImplementation((f) => f());
-  (React.useRef as jest.Mock).mockImplementation(() => element);
-
-  shallowRender({ scrollToUncoveredLine: true });
-  expect(scroll).toHaveBeenCalled();
-
-  scroll.mockReset();
-  shallowRender({ scrollToUncoveredLine: false });
-  expect(scroll).not.toHaveBeenCalled();
-});
-
-function shallowRender(props: Partial<LineCoverageProps> = {}) {
-  return shallow(<LineCoverage line={{ line: 3, coverageStatus: 'covered' }} {...props} />);
-}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx
deleted file mode 100644 (file)
index bb4ed9b..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { mockIssue } from '../../../../helpers/testMocks';
-import { click } from '../../../../helpers/testUtils';
-import { ButtonPlain } from '../../../controls/buttons';
-import { LineIssuesIndicator, LineIssuesIndicatorProps } from '../LineIssuesIndicator';
-
-it('should render correctly', () => {
-  expect(shallowRender()).toMatchSnapshot('default');
-  expect(
-    shallowRender({
-      issues: [
-        mockIssue(false, { key: 'foo', type: 'VULNERABILITY' }),
-        mockIssue(false, { key: 'bar', type: 'VULNERABILITY' }),
-      ],
-    })
-  ).toMatchSnapshot('multiple issues, same type');
-  expect(
-    shallowRender({ issues: [mockIssue(false, { key: 'foo', type: 'VULNERABILITY' })] })
-  ).toMatchSnapshot('single issue');
-  expect(shallowRender({ issues: [] })).toMatchSnapshot('no issues');
-});
-
-it('should correctly handle click', () => {
-  const onClick = jest.fn();
-  const wrapper = shallowRender({ onClick });
-
-  click(wrapper.find(ButtonPlain));
-  expect(onClick).toHaveBeenCalled();
-});
-
-function shallowRender(props: Partial<LineIssuesIndicatorProps> = {}) {
-  return shallow(
-    <LineIssuesIndicator
-      issues={[
-        mockIssue(false, { key: 'foo', type: 'CODE_SMELL' }),
-        mockIssue(false, { key: 'bar', type: 'BUG' }),
-      ]}
-      line={{ line: 3 }}
-      onClick={jest.fn()}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx
deleted file mode 100644 (file)
index 388e130..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { shallow } from 'enzyme';
-import * as React from 'react';
-import { LineNumber, LineNumberProps } from '../LineNumber';
-
-it('should render correctly', () => {
-  expect(shallowRender({ displayOptions: false, line: { line: 12 } })).toMatchSnapshot(
-    'no options'
-  );
-});
-
-function shallowRender(props: Partial<LineNumberProps> = {}) {
-  return shallow(
-    <LineNumber displayOptions={true} firstLineNumber={10} line={{ line: 20 }} {...props} />
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap
deleted file mode 100644 (file)
index 9ba0983..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-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={16}
->
-  <Memo(LineNumber)
-    displayOptions={true}
-    firstLineNumber={1}
-    line={
-      {
-        "code": "<span class="k">import</span> java.util.<span class="sym-9 sym">ArrayList</span>;",
-        "coverageStatus": "covered",
-        "coveredConditions": 2,
-        "duplicated": false,
-        "isNew": true,
-        "line": 16,
-        "scmAuthor": "simon.brandhof@sonarsource.com",
-        "scmDate": "2018-12-11T10:48:39+0100",
-        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
-      }
-    }
-  />
-  <Memo(LineSCM)
-    line={
-      {
-        "code": "<span class="k">import</span> java.util.<span class="sym-9 sym">ArrayList</span>;",
-        "coverageStatus": "covered",
-        "coveredConditions": 2,
-        "duplicated": false,
-        "isNew": true,
-        "line": 16,
-        "scmAuthor": "simon.brandhof@sonarsource.com",
-        "scmDate": "2018-12-11T10:48:39+0100",
-        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
-      }
-    }
-  />
-  <td
-    className="source-meta source-line-issues"
-  />
-  <LineCode
-    displayLocationMarkers={false}
-    issueLocations={[]}
-    line={
-      {
-        "code": "<span class="k">import</span> java.util.<span class="sym-9 sym">ArrayList</span>;",
-        "coverageStatus": "covered",
-        "coveredConditions": 2,
-        "duplicated": false,
-        "isNew": true,
-        "line": 16,
-        "scmAuthor": "simon.brandhof@sonarsource.com",
-        "scmDate": "2018-12-11T10:48:39+0100",
-        "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
-      }
-    }
-    onLocationSelect={[MockFunction]}
-    onSymbolClick={[MockFunction]}
-    secondaryIssueLocations={[]}
-  />
-</tr>
-`;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap
deleted file mode 100644 (file)
index f7d9351..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`render code 1`] = `
-<td
-  className="source-line-code code"
-  data-line-number={16}
->
-  <div
-    className="source-line-code-inner"
-  >
-    <pre>
-      <span
-        className="k source-line-code-issue"
-        key="0"
-      >
-        impor
-      </span>
-      <span
-        className="k"
-        key="1"
-      >
-        t
-      </span>
-      <span
-        key="2"
-      >
-         java.util.
-      </span>
-      <span
-        className="sym-9 sym highlighted"
-        key="3"
-      >
-        ArrayList
-      </span>
-      <span
-        key="4"
-      >
-        ;
-      </span>
-    </pre>
-  </div>
-</td>
-`;
-
-exports[`render code: with additional child 1`] = `
-<td
-  className="source-line-code code"
-  data-line-number={16}
->
-  <div
-    className="source-line-code-inner"
-  >
-    <pre>
-      <span
-        className="k source-line-code-issue"
-        key="0"
-      >
-        impor
-      </span>
-      <span
-        className="k"
-        key="1"
-      >
-        t
-      </span>
-      <span
-        key="2"
-      >
-         java.util.
-      </span>
-      <span
-        className="sym-9 sym highlighted"
-        key="3"
-      >
-        ArrayList
-      </span>
-      <span
-        key="4"
-      >
-        ;
-      </span>
-    </pre>
-  </div>
-  <div>
-    additional child
-  </div>
-</td>
-`;
-
-exports[`render code: with secondary location 1`] = `
-<td
-  className="source-line-code code"
-  data-line-number={16}
->
-  <div
-    className="source-line-code-inner"
-  >
-    <pre>
-      <span
-        className="k source-line-code-issue"
-        key="0"
-      >
-        impor
-      </span>
-      <Tooltip
-        key="marker-1"
-        overlay={
-          <IssueMessageHighlighting
-            message="secondary-location-msg"
-          />
-        }
-        placement="top"
-      >
-        <LocationIndex
-          aria-current={false}
-          leading={false}
-          onClick={[Function]}
-          selected={false}
-        >
-          <ContextConsumer>
-            <Component />
-          </ContextConsumer>
-        </LocationIndex>
-      </Tooltip>
-      <span
-        className="k issue-location"
-        key="1"
-      >
-        t
-      </span>
-      <span
-        key="2"
-      >
-         java.util.
-      </span>
-      <span
-        className="sym-9 sym highlighted"
-        key="3"
-      >
-        ArrayList
-      </span>
-      <span
-        key="4"
-      >
-        ;
-      </span>
-    </pre>
-  </div>
-</td>
-`;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap
deleted file mode 100644 (file)
index 5707fd2..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly: default 1`] = `
-<td
-  className="source-meta source-line-issues source-line-with-issues"
-  data-line-number={3}
->
-  <Tooltip
-    overlay="source_viewer.issues_on_line.multiple_issues.source_viewer.issues_on_line.show"
-  >
-    <ButtonPlain
-      aria-label="source_viewer.issues_on_line.multiple_issues.source_viewer.issues_on_line.show"
-      onClick={[MockFunction]}
-    >
-      <IssueIcon
-        type="BUG"
-      />
-      <span
-        className="source-line-issues-counter"
-      >
-        2
-      </span>
-    </ButtonPlain>
-  </Tooltip>
-</td>
-`;
-
-exports[`should render correctly: multiple issues, same type 1`] = `
-<td
-  className="source-meta source-line-issues source-line-with-issues"
-  data-line-number={3}
->
-  <Tooltip
-    overlay="source_viewer.issues_on_line.X_issues_of_type_Y.source_viewer.issues_on_line.show.2.issue.type.VULNERABILITY.plural"
-  >
-    <ButtonPlain
-      aria-label="source_viewer.issues_on_line.X_issues_of_type_Y.source_viewer.issues_on_line.show.2.issue.type.VULNERABILITY.plural"
-      onClick={[MockFunction]}
-    >
-      <IssueIcon
-        type="VULNERABILITY"
-      />
-      <span
-        className="source-line-issues-counter"
-      >
-        2
-      </span>
-    </ButtonPlain>
-  </Tooltip>
-</td>
-`;
-
-exports[`should render correctly: no issues 1`] = `
-<td
-  className="source-meta source-line-issues"
-  data-line-number={3}
-/>
-`;
-
-exports[`should render correctly: single issue 1`] = `
-<td
-  className="source-meta source-line-issues source-line-with-issues"
-  data-line-number={3}
->
-  <Tooltip
-    overlay="source_viewer.issues_on_line.issue_of_type_X.source_viewer.issues_on_line.show.issue.type.VULNERABILITY"
-  >
-    <ButtonPlain
-      aria-label="source_viewer.issues_on_line.issue_of_type_X.source_viewer.issues_on_line.show.issue.type.VULNERABILITY"
-      onClick={[MockFunction]}
-    >
-      <IssueIcon
-        type="VULNERABILITY"
-      />
-    </ButtonPlain>
-  </Tooltip>
-</td>
-`;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap
deleted file mode 100644 (file)
index 4d1071f..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly: no options 1`] = `
-<td
-  className="source-meta source-line-number"
-  data-line-number={12}
->
-  12
-</td>
-`;
index c6985a0a98e3e8bcbb2b606f42fa4edd82c2a00f..fc40964ec8de2f1f29cf04346e67c62bd7f582bf 100644 (file)
@@ -23,33 +23,33 @@ describe('highlightSymbol', () => {
   it('should not highlight symbols with similar beginning', () => {
     // test all positions of sym-X in the string: beginning, middle and ending
     const tokens = [
-      { className: 'sym-18 b', markers: [], text: 'foo' },
-      { className: 'a sym-18', markers: [], text: 'foo' },
-      { className: 'a sym-18 b', markers: [], text: 'foo' },
-      { className: 'sym-1 d', markers: [], text: 'bar' },
-      { className: 'c sym-1', markers: [], text: 'bar' },
-      { className: 'c sym-1 d', markers: [], text: 'bar' },
+      { className: 'sym-18 b', markers: [], text: 'foo', modifiers: {} },
+      { className: 'a sym-18', markers: [], text: 'foo', modifiers: {} },
+      { className: 'a sym-18 b', markers: [], text: 'foo', modifiers: {} },
+      { className: 'sym-1 d', markers: [], text: 'bar', modifiers: {} },
+      { className: 'c sym-1', markers: [], text: 'bar', modifiers: {} },
+      { className: 'c sym-1 d', markers: [], text: 'bar', modifiers: {} },
     ];
     expect(highlightSymbol(tokens, 'sym-1')).toEqual([
-      { className: 'sym-18 b', markers: [], text: 'foo' },
-      { className: 'a sym-18', markers: [], text: 'foo' },
-      { className: 'a sym-18 b', markers: [], text: 'foo' },
-      { className: 'sym-1 d highlighted', markers: [], text: 'bar' },
-      { className: 'c sym-1 highlighted', markers: [], text: 'bar' },
-      { className: 'c sym-1 d highlighted', markers: [], text: 'bar' },
+      { className: 'sym-18 b', markers: [], text: 'foo', modifiers: {} },
+      { className: 'a sym-18', markers: [], text: 'foo', modifiers: {} },
+      { className: 'a sym-18 b', markers: [], text: 'foo', modifiers: {} },
+      { className: 'sym-1 d highlighted', markers: [], text: 'bar', modifiers: {} },
+      { className: 'c sym-1 highlighted', markers: [], text: 'bar', modifiers: {} },
+      { className: 'c sym-1 d highlighted', markers: [], text: 'bar', modifiers: {} },
     ]);
   });
 
   it('should highlight symbols marked twice', () => {
     const tokens = [
-      { className: 'sym sym-1 sym sym-2', markers: [], text: 'foo' },
-      { className: 'sym sym-1', markers: [], text: 'bar' },
-      { className: 'sym sym-2', markers: [], text: 'qux' },
+      { className: 'sym sym-1 sym sym-2', markers: [], text: 'foo', modifiers: {} },
+      { className: 'sym sym-1', markers: [], text: 'bar', modifiers: {} },
+      { className: 'sym sym-2', markers: [], text: 'qux', modifiers: {} },
     ];
     expect(highlightSymbol(tokens, 'sym-1')).toEqual([
-      { className: 'sym sym-1 sym sym-2 highlighted', markers: [], text: 'foo' },
-      { className: 'sym sym-1 highlighted', markers: [], text: 'bar' },
-      { className: 'sym sym-2', markers: [], text: 'qux' },
+      { className: 'sym sym-1 sym sym-2 highlighted', markers: [], text: 'foo', modifiers: {} },
+      { className: 'sym sym-1 highlighted', markers: [], text: 'bar', modifiers: {} },
+      { className: 'sym sym-2', markers: [], text: 'qux', modifiers: {} },
     ]);
   });
 });
index f93bdaf41e653b5604ec08d0b59bc2a7bb46c9de..0dcfdc73e210b1f7903d7697b43cd8abe0caa42c 100644 (file)
 import { uniq } from 'lodash';
 import { LinearIssueLocation } from '../../../types/types';
 
+export interface TokenModifiers {
+  isHighlighted?: boolean;
+  isLocation?: boolean;
+  isSelected?: boolean;
+  isUnderlined?: boolean;
+}
+
 export interface Token {
   className: string;
   markers: number[];
+  modifiers: TokenModifiers;
   text: string;
 }
 
@@ -39,7 +47,7 @@ export function splitByTokens(code: NodeListOf<ChildNode>, rootClassName = ''):
     }
     if (node.nodeType === 3 && node.nodeValue) {
       // TEXT NODE
-      tokens.push({ className: rootClassName, markers: [], text: node.nodeValue });
+      tokens.push({ className: rootClassName, markers: [], text: node.nodeValue, modifiers: {} });
     }
   });
   return tokens;
@@ -83,6 +91,7 @@ function part(str: string, from: number, to: number, acc: number): string {
 export function highlightIssueLocations(
   tokens: Token[],
   issueLocations: LinearIssueLocation[],
+  modifier: keyof TokenModifiers,
   rootClassName: string = ISSUE_LOCATION_CLASS
 ): Token[] {
   issueLocations.forEach((location) => {
@@ -104,6 +113,10 @@ export function highlightIssueLocations(
             : token.className;
         nextTokens.push({
           className: newClassName,
+          modifiers: {
+            ...token.modifiers,
+            [modifier]: true,
+          },
           markers:
             !markerAdded && location.index != null
               ? uniq([...token.markers, location.index])
@@ -121,3 +134,53 @@ export function highlightIssueLocations(
   });
   return tokens;
 }
+
+export const getHighlightedTokens = (params: {
+  code: string | undefined;
+  highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
+  highlightedSymbols: string[] | undefined;
+  issueLocations: LinearIssueLocation[];
+  secondaryIssueLocations: LinearIssueLocation[];
+}) => {
+  const {
+    code,
+    highlightedLocationMessage,
+    highlightedSymbols,
+    issueLocations,
+    secondaryIssueLocations,
+  } = params;
+
+  const container = document.createElement('div');
+  container.innerHTML = code ?? '';
+  let tokens = splitByTokens(container.childNodes);
+
+  if (highlightedSymbols) {
+    highlightedSymbols.forEach((symbol) => {
+      tokens = highlightSymbol(tokens, symbol);
+    });
+  }
+
+  if (issueLocations.length > 0) {
+    tokens = highlightIssueLocations(tokens, issueLocations, 'isUnderlined', 'isUnderlined');
+  }
+
+  if (secondaryIssueLocations) {
+    tokens = highlightIssueLocations(
+      tokens,
+      secondaryIssueLocations,
+      'isLocation',
+      'issue-location'
+    );
+
+    if (highlightedLocationMessage) {
+      const location = secondaryIssueLocations.find(
+        (location) => location.index === highlightedLocationMessage.index
+      );
+      if (location) {
+        tokens = highlightIssueLocations(tokens, [location], 'isSelected', 'selected');
+      }
+    }
+  }
+
+  return tokens;
+};
index bafd011e8ffd750e4b5c26b53d8a16b064437ebf..b2d66dbe1a66c03767148e6c32021db057ec6127 100644 (file)
@@ -42,3 +42,16 @@ export function optimizeLocationMessage(
     ? highlightedLocationMessage
     : undefined;
 }
+
+/**
+ * Parse lineCode HTML and return text nodes content only
+ */
+export const getLineCodeAsPlainText = (lineCode?: string) => {
+  if (!lineCode) {
+    return '';
+  }
+  const domParser = new DOMParser();
+  const domDoc = domParser.parseFromString(lineCode, 'text/html');
+  const bodyElements = domDoc.getElementsByTagName('body');
+  return bodyElements.length && bodyElements[0].textContent ? bodyElements[0].textContent : '';
+};
index eeff7ebe1a7c7a197d7e8fbdf68de3b9bc29a947..eb1436e837bf7f240ff0ed6f59acc5fd4f437cd3 100644 (file)
@@ -44,6 +44,7 @@ export interface TooltipProps {
   // default behavior of tabbing (other changes should be done outside of this component to make it work)
   // See example DocumentationTooltip
   isInteractive?: boolean;
+  classNameInner?: string;
 }
 
 interface Measurements {
@@ -394,11 +395,12 @@ export class TooltipInner extends React.Component<TooltipProps, State> {
 
   renderOverlay() {
     const isVisible = this.isVisible();
-    const { classNameSpace = 'tooltip', isInteractive, overlay } = this.props;
-
+    const { classNameSpace = 'tooltip', isInteractive, overlay, classNameInner } = this.props;
     return (
       <div
-        className={classNames(`${classNameSpace}-inner sw-font-sans`, { hidden: !isVisible })}
+        className={classNames(`${classNameSpace}-inner sw-font-sans`, classNameInner, {
+          hidden: !isVisible,
+        })}
         id={this.id}
         role="tooltip"
         aria-hidden={!isInteractive || !isVisible}
index f4552defa6aa902d4cce4302df0870747528190e..fe7a4e1a8cd8145b2827b0ff07c9878fe73605cb 100644 (file)
@@ -74,7 +74,7 @@ export class WorkspaceComponentViewer extends React.PureComponent<Props> {
 
     if (this.container && this.props.component.line) {
       const row = this.container.querySelector(
-        `.source-line[data-line-number="${this.props.component.line}"]`
+        `.it__source-line[data-line-number="${this.props.component.line}"]`
       );
       if (row) {
         row.scrollIntoView({ block: 'center' });
index 0edbcf7a554ea7aeb793a719f7211d198345dd15..cd5fcc34769d87d9d7659426206b289a8ddcbb23 100644 (file)
@@ -625,12 +625,14 @@ export interface SnippetsByComponent {
 export interface SourceLine {
   code?: string;
   conditions?: number;
+  coverageBlock?: number;
   coverageStatus?: SourceLineCoverageStatus;
   coveredConditions?: number;
   duplicated?: boolean;
   isNew?: boolean;
   line: number;
   lineHits?: number;
+  newCodeBlock?: number;
   scmAuthor?: string;
   scmDate?: string;
   scmRevision?: string;
index 162fa08cf32dd532c7f038f9774aeb6d489e3aa8..ac53261316d25a3aa406567d27da7031be89afe6 100644 (file)
@@ -66,19 +66,19 @@ module.exports = plugin(({ addUtilities, theme }) => {
     '.code': {
       'font-family': theme('fontFamily.mono'),
       'font-size': theme('fontSize.sm'),
-      'line-height': theme('fontSize').sm[1],
+      'line-height': theme('fontSize').code[1],
       'font-weight': theme('fontWeight.regular'),
     },
     '.code-highlight': {
       'font-family': theme('fontFamily.mono'),
       'font-size': theme('fontSize.sm'),
-      'line-height': theme('fontSize').sm[1],
+      'line-height': theme('fontSize').code[1],
       'font-weight': theme('fontWeight.bold'),
     },
     '.code-comment': {
       'font-family': theme('fontFamily.mono'),
       'font-size': theme('fontSize.sm'),
-      'line-height': theme('fontSize').sm[1],
+      'line-height': theme('fontSize').code[1],
       'font-style': 'italic',
     },
   };
index af6c8b43a7f8e39673c8aa746f829c9692e172af..f32a56017bb16227e8d3a61d323a7924eddc996f 100644 (file)
@@ -34,6 +34,7 @@ module.exports = {
     },
     // Define font sizes
     fontSize: {
+      code: ['0.875rem', '1.125rem'], // 14px / 18px
       sm: ['0.875rem', '1.25rem'], // 14px / 20px
       base: ['1rem', '1.5rem'], // 16px / 24px
       md: ['1.313rem', '1.75rem'], // 21px / 28px
index 58dfb084222f6e4cc2bd82bda67937eddf005d8d..fc9ba12965021ec13952aac39890651255b2ba02 100644 (file)
@@ -3168,7 +3168,13 @@ source_viewer.loading_more_code=Loading More Code...
 
 source_viewer.expand_above=Show previous few lines of code
 source_viewer.expand_below=Show next few lines of code
-
+source_viewer.copy_permalink=Copy Permalink
+source_viewer.copy_line=Copy Line
+source_viewer.copied_to_clipboard=Copied to Clipboard
+source_viewer.new_code=New Code
+source_viewer.coverage.covered=Covered code
+source_viewer.coverage.uncovered=Uncovered code
+source_viewer.coverage.partially-covered=Partially covered code
 #------------------------------------------------------------------------------
 #
 # WORKSPACE