aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--server/sonar-web/design-system/src/components/DatePicker.tsx2
-rw-r--r--server/sonar-web/design-system/src/components/DropdownMenu.tsx6
-rw-r--r--server/sonar-web/design-system/src/components/DropdownToggler.tsx2
-rw-r--r--server/sonar-web/design-system/src/components/IssueLocationMarker.tsx74
-rw-r--r--server/sonar-web/design-system/src/components/Link.tsx7
-rw-r--r--server/sonar-web/design-system/src/components/OutsideClickHandler.tsx2
-rw-r--r--server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx87
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx6
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx58
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx (renamed from server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx)31
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx56
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap12
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap18
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap237
-rw-r--r--server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap24
-rw-r--r--server/sonar-web/design-system/src/components/buttons.tsx31
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx97
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx49
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineMarker.tsx98
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineNumber.tsx95
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineStyles.tsx145
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineToken.tsx91
-rw-r--r--server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx49
-rw-r--r--server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx85
-rw-r--r--server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx47
-rw-r--r--server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx37
-rw-r--r--server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx43
-rw-r--r--server/sonar-web/design-system/src/components/icons/index.ts1
-rw-r--r--server/sonar-web/design-system/src/components/index.ts9
-rw-r--r--server/sonar-web/design-system/src/theme/colors.ts40
-rw-r--r--server/sonar-web/design-system/src/theme/light.ts11
-rw-r--r--server/sonar-web/src/main/js/app/styles/sonar-colorizer.css92
-rw-r--r--server/sonar-web/src/main/js/app/styles/sonar.ts1
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx268
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx3
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts39
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx2
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx125
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx4
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx84
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx65
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx291
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx235
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx79
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx73
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx31
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx65
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx59
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx77
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx32
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx96
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx53
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx48
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx63
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap64
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap150
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap78
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap10
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts36
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts65
-rw-r--r--server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts13
-rw-r--r--server/sonar-web/src/main/js/components/controls/Tooltip.tsx8
-rw-r--r--server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx2
-rw-r--r--server/sonar-web/src/main/js/types/types.ts2
-rw-r--r--server/sonar-web/tailwind-utilities.js6
-rw-r--r--server/sonar-web/tailwind.base.config.js1
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties8
72 files changed, 2381 insertions, 1531 deletions
diff --git a/server/sonar-web/design-system/src/components/DatePicker.tsx b/server/sonar-web/design-system/src/components/DatePicker.tsx
index 601eae00971..a09e488afc5 100644
--- a/server/sonar-web/design-system/src/components/DatePicker.tsx
+++ b/server/sonar-web/design-system/src/components/DatePicker.tsx
@@ -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';
diff --git a/server/sonar-web/design-system/src/components/DropdownMenu.tsx b/server/sonar-web/design-system/src/components/DropdownMenu.tsx
index b9e430d7623..d822cf68437 100644
--- a/server/sonar-web/design-system/src/components/DropdownMenu.tsx
+++ b/server/sonar-web/design-system/src/components/DropdownMenu.tsx
@@ -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}
diff --git a/server/sonar-web/design-system/src/components/DropdownToggler.tsx b/server/sonar-web/design-system/src/components/DropdownToggler.tsx
index e81ac54bbad..ee0f65e5733 100644
--- a/server/sonar-web/design-system/src/components/DropdownToggler.tsx
+++ b/server/sonar-web/design-system/src/components/DropdownToggler.tsx
@@ -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
index 00000000000..f9886fd80b9
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/IssueLocationMarker.tsx
@@ -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`}
+ }
+`;
diff --git a/server/sonar-web/design-system/src/components/Link.tsx b/server/sonar-web/design-system/src/components/Link.tsx
index 8f1bb78e43f..c1176015223 100644
--- a/server/sonar-web/design-system/src/components/Link.tsx
+++ b/server/sonar-web/design-system/src/components/Link.tsx
@@ -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`}
+`;
diff --git a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
index fb33549c1d1..b13afd47c9f 100644
--- a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
+++ b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx
@@ -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
index 00000000000..8e8d7b57048
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx
@@ -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';
diff --git a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
index 1e68ae1d22e..41da9ead332 100644
--- a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
+++ b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx
@@ -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
index 00000000000..5fa1bc69eb4
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx
@@ -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/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx
index 388e130a15a..aee31448c74 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx
+++ b/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx
@@ -17,18 +17,29 @@
* 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';
+import { screen } from '@testing-library/react';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { LineNumber } from '../code-line/LineNumber';
-it('should render correctly', () => {
- expect(shallowRender({ displayOptions: false, line: { line: 12 } })).toMatchSnapshot(
- 'no options'
- );
+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 shallowRender(props: Partial<LineNumberProps> = {}) {
- return shallow(
- <LineNumber displayOptions={true} firstLineNumber={10} line={{ line: 20 }} {...props} />
+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
index 00000000000..0bdc69d22b2
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx
@@ -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}
+ />
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
index 5b27cf29f1f..ec00317fed2 100644
--- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
+++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap
@@ -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;
}
diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap
index 45b4dd7b63b..d7fed058225 100644
--- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap
+++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap
@@ -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
index 00000000000..88462699f2f
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap
@@ -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
index 00000000000..2bbcd9fed07
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx
index 6e955a2bb04..dd679d3ae56 100644
--- a/server/sonar-web/design-system/src/components/buttons.tsx
+++ b/server/sonar-web/design-system/src/components/buttons.tsx
@@ -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
index 00000000000..726b3543a49
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx
@@ -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
index 00000000000..a5522960a15
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx
@@ -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
index 00000000000..cf62c597baa
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineMarker.tsx
@@ -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
index 00000000000..243b2df0a93
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineNumber.tsx
@@ -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
index 00000000000..88975e22e16
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx
@@ -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
index 00000000000..b96667a6df9
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineToken.tsx
@@ -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
index 00000000000..5b825c82608
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx
@@ -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
index 00000000000..134788f9618
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx
@@ -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
index 00000000000..4f5bbc4d99b
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx
@@ -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
index 00000000000..70bf1d3a71d
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx
@@ -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
index 00000000000..fae7278a2f6
--- /dev/null
+++ b/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx
@@ -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>
+ );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts
index 492863fb778..b811ad0071d 100644
--- a/server/sonar-web/design-system/src/components/icons/index.ts
+++ b/server/sonar-web/design-system/src/components/icons/index.ts
@@ -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';
diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts
index f1880121fde..fb80d478c1a 100644
--- a/server/sonar-web/design-system/src/components/index.ts
+++ b/server/sonar-web/design-system/src/components/index.ts
@@ -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';
diff --git a/server/sonar-web/design-system/src/theme/colors.ts b/server/sonar-web/design-system/src/theme/colors.ts
index 785f6f07c8b..81bb8e5dd6d 100644
--- a/server/sonar-web/design-system/src/theme/colors.ts
+++ b/server/sonar-web/design-system/src/theme/colors.ts
@@ -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],
+ },
};
diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts
index fb4ddc7a1d0..dd154c09921 100644
--- a/server/sonar-web/design-system/src/theme/light.ts
+++ b/server/sonar-web/design-system/src/theme/light.ts
@@ -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
index bc871a101f3..00000000000
--- a/server/sonar-web/src/main/js/app/styles/sonar-colorizer.css
+++ /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);
- }
-}
diff --git a/server/sonar-web/src/main/js/app/styles/sonar.ts b/server/sonar-web/src/main/js/app/styles/sonar.ts
index ecaa397f678..c8f2b691709 100644
--- a/server/sonar-web/src/main/js/app/styles/sonar.ts
+++ b/server/sonar-web/src/main/js/app/styles/sonar.ts
@@ -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';
diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx
index 014471b2085..59d2f52ade8 100644
--- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx
@@ -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 () => {
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
index 93e16726fbc..f6029dfa639 100644
--- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
@@ -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}
/>
))}
</>
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
index d1ec05f8ba0..8e5e8551a02 100644
--- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
@@ -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}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
index 7a0becf21bb..b428953ca11 100644
--- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/SnippetViewer.tsx
@@ -20,13 +20,15 @@
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);
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx
index cdbcdb84b88..df138d37b67 100644
--- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx
@@ -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={{}}
diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
index 10287fb3267..f84d5db25b0 100644
--- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
+++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts
@@ -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(
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
index 8f09fcc0e4b..086527df353 100644
--- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
+++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
@@ -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 () => {
diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
index 78a329c7ddd..3434874b0d7 100644
--- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
+++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
@@ -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}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx
index e4785151980..3f813f76da8 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx
@@ -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>
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
index f2a24ae873b..7d473ae4de0 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
@@ -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>
);
}
}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx
index 541ab1cfcb3..865ed68ea6a 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx
@@ -30,3 +30,7 @@ export const SourceViewerContext = React.createContext<SourceViewerContextShape>
branchLike: {} as BranchLike,
file: {} as SourceViewerFile,
});
+
+export function useSourceViewerContext() {
+ return React.useContext(SourceViewerContext);
+}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx
index 1439f083790..4fe7c105ac2 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx
@@ -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 () => {
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx
index d69d6418d05..ca1c33e6722 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx
@@ -17,16 +17,21 @@
* 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>
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
index 7d3d48fd7be..e804c11b9df 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
@@ -18,18 +18,21 @@
* 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>
+ );
}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx
index 87745243401..1976957c122 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx
@@ -17,35 +17,42 @@
* 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
index 55bf4b7879c..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx
+++ /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);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx
index 86d2fde8b8c..da2c306f5ab 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx
@@ -17,15 +17,11 @@
* 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} />
);
}
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx
index f2cf3b718b6..6a6bdcfaa0a 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx
@@ -17,15 +17,15 @@
* 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
index eb1e4d9e912..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx
+++ /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);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx
index e3c79711441..a647b8b0c5a 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx
@@ -17,47 +17,38 @@
* 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);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx
index b9e31b605ad..5dd9ec882de 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx
@@ -17,48 +17,67 @@
* 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);
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx
index cd2f934bd83..5d1272abc2e 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx
+++ b/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx
@@ -17,41 +17,43 @@
* 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
index 9941ae567ce..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx
+++ /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
index 53fa5ed13e3..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx
+++ /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
index e687e93c345..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx
+++ /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
index bb4ed9bf390..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx
+++ /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__/__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
index 9ba0983bbf1..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap
+++ /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
index f7d93519ce9..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap
+++ /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
index 5707fd2491d..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap
+++ /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
index 4d1071f9d80..00000000000
--- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap
+++ /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>
-`;
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts
index c6985a0a98e..fc40964ec8d 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts
@@ -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: {} },
]);
});
});
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts
index f93bdaf41e6..0dcfdc73e21 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts
@@ -20,9 +20,17 @@
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;
+};
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts
index bafd011e8ff..b2d66dbe1a6 100644
--- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts
+++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts
@@ -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 : '';
+};
diff --git a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx
index eeff7ebe1a7..eb1436e837b 100644
--- a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx
+++ b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx
@@ -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}
diff --git a/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx b/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx
index f4552defa6a..fe7a4e1a8cd 100644
--- a/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx
+++ b/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx
@@ -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' });
diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts
index 0edbcf7a554..cd5fcc34769 100644
--- a/server/sonar-web/src/main/js/types/types.ts
+++ b/server/sonar-web/src/main/js/types/types.ts
@@ -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;
diff --git a/server/sonar-web/tailwind-utilities.js b/server/sonar-web/tailwind-utilities.js
index 162fa08cf32..ac53261316d 100644
--- a/server/sonar-web/tailwind-utilities.js
+++ b/server/sonar-web/tailwind-utilities.js
@@ -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',
},
};
diff --git a/server/sonar-web/tailwind.base.config.js b/server/sonar-web/tailwind.base.config.js
index af6c8b43a7f..f32a56017bb 100644
--- a/server/sonar-web/tailwind.base.config.js
+++ b/server/sonar-web/tailwind.base.config.js
@@ -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
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index 58dfb084222..fc9ba129650 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -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