interface Props {
className?: string;
+ escapeDom?: boolean;
htmlAsString: string;
language?: string;
wrap?: boolean | 'words';
};
export function CodeSyntaxHighlighter(props: Props) {
- const { className, htmlAsString, language, wrap } = props;
+ const { className, htmlAsString, language, wrap, escapeDom = true } = props;
let highlightedHtmlAsString = htmlAsString;
htmlAsString.match(GLOBAL_REGEXP)?.forEach((codeBlock) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [, tag, attributes, code] = SINGLE_REGEXP.exec(codeBlock)!;
- const unescapedCode = htmlDecode(code);
-
+ const unescapedCode = escapeDom ? htmlDecode(code) : code;
let highlightedCode: HighlightResult;
try {
});
it('should render as non-button', () => {
- setupWithProps({ as: 'div' });
+ setupWithProps({ as: 'div', onIssueSelect: undefined });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
*/
import { render } from '../../helpers/testUtils';
import { FCProps } from '../../types/misc';
-import { LineWrapper } from '../code-line/LineWrapper';
+import { LineWrapper, SuggestedLineWrapper } from '../code-line/LineWrapper';
it('should render with correct styling', () => {
expect(setupWithProps().container).toMatchSnapshot();
expect(container.firstChild).toHaveStyle({ '--line-background': 'rgb(225,230,243)' });
});
+it('should properly setup css grid columns for Suggested Line', () => {
+ const container = render(<SuggestedLineWrapper />, {
+ container: document.createElement('div'),
+ });
+ expect(container.container.firstChild).toHaveStyle({
+ '--columns': '44px 26px 1rem 1fr',
+ });
+});
+
function setupWithProps(props: Partial<FCProps<typeof LineWrapper>> = {}) {
return render(
<LineWrapper displayCoverage displaySCM duplicationsCount={2} highlighted={false} {...props} />,
user-select: none;
}
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
background-color: rgb(239,242,249);
}
user-select: none;
}
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
background-color: rgb(239,242,249);
}
user-select: none;
}
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
background-color: rgb(239,242,249);
}
user-select: none;
}
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
background-color: rgb(239,242,249);
}
user-select: none;
}
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
background-color: rgb(239,242,249);
}
font-size: 1rem;
line-height: 1.5rem;
font-weight: 600;
+ cursor: default;
border: 1px solid rgb(253,162,155);
color: rgb(62,67,87);
word-break: break-word;
box-shadow: 0px 1px 3px 0px rgba(29,33,47,0.05),0px 1px 25px 0px rgba(29,33,47,0.05);
}
+.emotion-2 {
+ all: unset;
+ cursor: pointer;
+}
+
+.emotion-2:focus-visible {
+ background-color: rgb(239,242,249);
+}
+
<div>
- <button
+ <div
class="emotion-0 emotion-1"
data-issue="key"
>
- message
- </button>
+ <button
+ class="emotion-2 emotion-3"
+ >
+ message
+ </button>
+ </div>
</div>
`;
interface Props {
as?: React.ElementType;
className?: string;
+ getFixButton?: React.ReactNode;
issueKey: string;
message: React.ReactNode;
onIssueSelect?: (issueKey: string) => void;
}
function LineFindingFunc(
- { as, message, issueKey, selected = true, className, onIssueSelect }: Props,
+ { as, getFixButton, message, issueKey, selected = true, className, onIssueSelect }: Props,
ref: Ref<HTMLButtonElement>,
) {
- return (
+ return selected ? (
+ <LineFindingStyled
+ as="div"
+ className={className}
+ data-issue={issueKey}
+ ref={ref}
+ selected={selected}
+ >
+ {onIssueSelect ? (
+ <BareButton
+ onClick={() => {
+ onIssueSelect(issueKey);
+ }}
+ >
+ {message}
+ </BareButton>
+ ) : (
+ message
+ )}
+ {getFixButton}
+ </LineFindingStyled>
+ ) : (
<LineFindingStyled
as={as}
className={className}
${tw`sw-box-border`}
${(props) => (props.selected ? tw`sw-py-3` : tw`sw-py-2`)};
${(props) => (props.selected ? tw`sw-body-md-highlight` : tw`sw-body-sm`)};
+ ${(props) => (props.selected ? tw`sw-cursor-default` : tw`sw-cursor-pointer`)};
border: ${(props) =>
props.selected
import styled from '@emotion/styled';
import tw from 'twin.macro';
import { themeBorder, themeColor, themeContrast } from '../../helpers/theme';
+import { BareButton } from '../../sonar-aligned';
export const SCMHighlight = styled.h6`
color: ${themeColor('tooltipHighlight')};
color: ${themeContrast('codeLineUncoveredUnderline')};
background-color: ${themeColor('codeLineUncoveredUnderline')};
`;
+
+export const LineCodeEllipsisStyled = styled(BareButton)`
+ ${tw`sw-flex sw-items-center sw-gap-2`}
+ ${tw`sw-px-2 sw-py-1`}
+${tw`sw-code`}
+${tw`sw-w-full`}
+${tw`sw-box-border`}
+color: ${themeColor('codeLineEllipsisContrast')};
+ background-color: ${themeColor('codeLineEllipsis')};
+
+ border-top: ${themeBorder('default', 'codeLineBorder')};
+ border-bottom: ${themeBorder('default', 'codeLineBorder')};
+
+ &:hover {
+ color: ${themeColor('codeLineEllipsisHoverContrast')};
+ background-color: ${themeColor('codeLineEllipsisHover')};
+ }
+`;
/>
);
}
+
+export function SuggestedLineWrapper(props: Readonly<HTMLAttributes<HTMLDivElement>>) {
+ const theme = useTheme();
+ return (
+ <LineStyled
+ as="div"
+ style={{
+ '--columns': `44px 26px 1rem 1fr`,
+ '--line-background': themeColor('codeLine')({ theme }),
+ }}
+ {...props}
+ />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { IconProps } from '~components/icons/Icon';
+import { UnfoldDownIcon } from './UnfoldDownIcon';
+import { UnfoldIcon } from './UnfoldIcon';
+import { UnfoldUpIcon } from './UnfoldUpIcon';
+
+export const enum CodeEllipsisDirection {
+ Up = 'up',
+ Down = 'down',
+ Middle = 'middle',
+}
+
+interface Props extends IconProps {
+ direction: CodeEllipsisDirection;
+}
+
+export function CodeEllipsisIcon({ direction, ...props }: Readonly<Props>) {
+ if (direction === CodeEllipsisDirection.Up) {
+ return <UnfoldUpIcon {...props} />;
+ } else if (direction === CodeEllipsisDirection.Down) {
+ return <UnfoldDownIcon {...props} />;
+ }
+ return <UnfoldIcon {...props} />;
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { keyframes, useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { themeColor } from '../../helpers/theme';
+
+export function InProgressVisual() {
+ const theme = useTheme();
+
+ return (
+ <svg className="svg-animated" height="168" width="168" xmlns="http://www.w3.org/2000/svg">
+ <path
+ d="M149 151.15v-61.5c-6 48.4-49.17 61.34-70 61.5h70Z"
+ fill={themeColor('illustrationShade')({ theme })}
+ />
+ <path
+ d="M50.94 16.79 34 9.79 37.8 4l13.14 12.79ZM48.5 24.46 38 27.93V21l10.5 3.46ZM125.55 37.07l3.63-9.07 5.1 4.7-8.73 4.37ZM125 43.46 141.5 40v6.93L125 43.46ZM56.93 10.59 50 2.57 56.51 0l.42 10.59Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ <path
+ d="M19 57.15v95h8v-95h-8ZM33 73.15h15v-8H33v8ZM56 73.15h15v-8H56v8Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ />
+ <path
+ clipRule="evenodd"
+ d="M20 157a7 7 0 0 1-7-7V61a7 7 0 0 1 7-7h28.5v6H20a1 1 0 0 0-1 1v16.88h63v6.24H19V150a1 1 0 0 0 1 1h128a1 1 0 0 0 1-1V61a1 1 0 0 0-1-1h-11v-6h11a7 7 0 0 1 7 7v89a7 7 0 0 1-7 7H20Z"
+ fill={themeColor('illustrationOutline')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M91 112.15H66v-6h25v6ZM62.09 129.5 48.6 142.54l-8.72-8.61 4.22-4.27 4.55 4.49 9.25-8.97 4.18 4.32ZM62.09 105.31 48.6 118.35l-8.72-8.6 4.22-4.28 4.55 4.5L57.9 101l4.18 4.31ZM91 137.34H66v-6h25v6Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ <Wheel>
+ <path
+ clipRule="evenodd"
+ d="m115.17 46.11-7.2-4.15a24.21 24.21 0 0 0 1.72-6.41H118v-6.1h-8.31c-.28-2.24-.87-4.4-1.72-6.4l7.2-4.16-3.05-5.28-7.2 4.16a24.55 24.55 0 0 0-4.69-4.7l4.16-7.2-5.28-3.04-4.15 7.2a24.21 24.21 0 0 0-6.41-1.72V0h-6.1v8.31c-2.24.28-4.4.87-6.4 1.72l-4.16-7.2-5.28 3.05 4.16 7.2a24.52 24.52 0 0 0-4.7 4.69l-7.2-4.16-3.04 5.28 7.2 4.15a24.2 24.2 0 0 0-1.72 6.41H53v6.1h8.31c.28 2.24.87 4.4 1.72 6.4l-7.2 4.16 3.05 5.28 7.2-4.16a24.52 24.52 0 0 0 4.69 4.7l-4.16 7.2 5.28 3.04 4.15-7.2c2.02.85 4.17 1.44 6.41 1.72V65h6.1v-8.31a24.2 24.2 0 0 0 6.4-1.72l4.16 7.2 5.28-3.05-4.16-7.2a24.51 24.51 0 0 0 4.7-4.69l7.2 4.16 3.04-5.28ZM85.5 51a18.5 18.5 0 1 0 0-37 18.5 18.5 0 0 0 0 37Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ fillRule="evenodd"
+ />
+ </Wheel>
+ <path
+ clipRule="evenodd"
+ d="M73 32.5a12.5 12.5 0 0 0 25 0h6a18.5 18.5 0 1 1-37 0h6Z"
+ fill={themeColor('illustrationInlineBorder')({ theme })}
+ fillRule="evenodd"
+ />
+ <WheelInverted>
+ <path
+ clipRule="evenodd"
+ d="m105.3 54.74 4.74-2.74 1.93 3.34a18.95 18.95 0 0 1 14.2.06l1.97-3.4 4.74 2.74-1.98 3.44A18.98 18.98 0 0 1 137.76 70H142v6h-4.24a18.98 18.98 0 0 1-6.98 11.91l2.1 3.65-4.74 2.74-2.1-3.64a18.95 18.95 0 0 1-13.93.05l-2.07 3.6-4.74-2.75 2.05-3.55A18.98 18.98 0 0 1 100.24 76H96v-6h4.24a18.98 18.98 0 0 1 6.99-11.91l-1.93-3.35ZM119 86a13 13 0 1 0 0-26 13 13 0 0 0 0 26Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ </WheelInverted>
+ <circle cx="119" cy="73" fill={themeColor('illustrationPrimary')({ theme })} r="5" />
+ </svg>
+ );
+}
+
+const rotateKeyFrame = keyframes`
+ from {
+ transform: rotateZ(0deg);
+ }
+ to {
+ transform: rotateZ(360deg);
+ }
+`;
+
+const rotateKeyFrameInverse = keyframes`
+ from {
+ transform: rotateZ(360deg);
+ }
+ to {
+ transform: rotateZ(0deg);
+ }
+`;
+
+const Wheel = styled.g`
+ transform-origin: 85.5px 32.5px 0;
+ animation: ${rotateKeyFrame} 3s infinite;
+`;
+
+const WheelInverted = styled.g`
+ transform-origin: 119px 73px 0;
+ animation: ${rotateKeyFrameInverse} 3s infinite;
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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';
+
+interface Props {
+ className?: string;
+}
+
+export function OverviewQGNotComputedIcon({ className }: Readonly<Props>) {
+ const theme = useTheme();
+
+ return (
+ <svg
+ className={className}
+ fill="none"
+ height="168"
+ role="img"
+ viewBox="0 0 168 168"
+ width="168"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ clipRule="evenodd"
+ d="M149.542 26.472L141.248 37.2099C140.456 38.2345 140.645 39.7068 141.67 40.4983C142.695 41.2897 144.167 41.1007 144.959 40.076L153.253 29.3382C154.044 28.3135 153.855 26.8413 152.831 26.0498C151.806 25.2583 150.334 25.4473 149.542 26.472ZM137.915 45.3598C141.625 48.2252 146.955 47.5408 149.82 43.8312L158.114 33.0934C160.98 29.3837 160.295 24.0536 156.586 21.1883C152.876 18.3229 147.546 19.0072 144.681 22.7168L136.386 33.4547C133.521 37.1643 134.205 42.4944 137.915 45.3598Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M149.385 57.9371C149.385 46.1503 139.83 36.5952 128.043 36.5952C116.257 36.5952 106.702 46.1503 106.702 57.9371C106.702 69.7238 116.257 79.2789 128.043 79.2789C139.83 79.2789 149.385 69.7238 149.385 57.9371ZM155.528 57.9371C155.528 42.7576 143.223 30.4523 128.043 30.4523C112.864 30.4523 100.559 42.7576 100.559 57.9371C100.559 73.1165 112.864 85.4218 128.043 85.4218C143.223 85.4218 155.528 73.1165 155.528 57.9371Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M143.6 57.937C143.6 49.3459 136.635 42.3814 128.044 42.3814C119.453 42.3814 112.489 49.3459 112.489 57.937C112.489 66.5281 119.453 73.4925 128.044 73.4925C136.635 73.4925 143.6 66.528 143.6 57.937ZM149.743 57.937C149.743 45.9532 140.028 36.2385 128.044 36.2385C116.06 36.2385 106.346 45.9532 106.346 57.937C106.346 69.9207 116.06 79.6355 128.044 79.6355C140.028 79.6355 149.743 69.9207 149.743 57.937Z"
+ fill={themeColor('illustrationShade')({ theme })}
+ fillRule="evenodd"
+ />
+ <path d="M24 40L24 135H32L32 40H24Z" fill={themeColor('illustrationSecondary')({ theme })} />
+ <path
+ d="M38 56L53 56L53 48L38 48L38 56Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ />
+ <path
+ d="M61 56L76 56L76 48L61 48L61 56Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ />
+ <path
+ clipRule="evenodd"
+ d="M88 67.5746H21V61.3297H88V67.5746Z"
+ fill={themeColor('illustrationOutline')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M18 133C18 136.866 21.134 140 25 140H153C156.866 140 160 136.866 160 133V78H154V133C154 133.552 153.552 134 153 134H25C24.4477 134 24 133.552 24 133V44C24 43.4477 24.4477 43 25 43H72V37H25C21.134 37 18 40.134 18 44V133Z"
+ fill={themeColor('illustrationOutline')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M69.2432 103.219L78.7954 93.6672L74.5527 89.4245L60.7578 103.219L74.5527 117.014L78.7954 112.771L69.2432 103.219Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M108.906 103.219L99.3538 93.6672L103.596 89.4246L117.391 103.219L103.596 117.014L99.3538 112.771L108.906 103.219Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M81.7179 119.862L91.0929 84.2365L96.8953 85.7635L87.5203 121.388L81.7179 119.862Z"
+ fill={themeColor('illustrationSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ d="M51 128.953C51 141.379 40.9264 151.453 28.5 151.453C16.0736 151.453 6 141.379 6 128.953C6 116.526 16.0736 106.453 28.5 106.453C40.9264 106.453 51 116.526 51 128.953Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ <path
+ clipRule="evenodd"
+ d="M25 131.953V113.953H31V131.953H25Z"
+ fill={themeColor('backgroundSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ clipRule="evenodd"
+ d="M25 142.453L25 136.453L31 136.453L31 142.453L25 142.453Z"
+ fill={themeColor('backgroundSecondary')({ theme })}
+ fillRule="evenodd"
+ />
+ <path
+ d="M105.398 35.2089L90.7238 24.2245L95.8489 19.5626L105.398 35.2089Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ <path
+ d="M99 41.5242L88.5 44.9883L88.5 38.0601L99 41.5242Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ <path
+ d="M139.228 86.8865L147.417 92.2112L141.826 96.3028L139.228 86.8865Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ <path
+ d="M132 88.5242L135.464 105.024H128.536L132 88.5242Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ <path
+ d="M114 29.5242L110.536 19.7742L117.464 19.7742L114 29.5242Z"
+ fill={themeColor('illustrationPrimary')({ theme })}
+ />
+ </svg>
+ );
+}
export { ChevronRightIcon } from './ChevronRightIcon';
export { ClockIcon } from './ClockIcon';
export { CloseIcon } from './CloseIcon';
+export { CodeEllipsisDirection, CodeEllipsisIcon } from './CodeEllipsisIcon';
export { CodeSmellIcon } from './CodeSmellIcon';
export { CollapseIcon } from './CollapseIcon';
export { CommentIcon } from './CommentIcon';
export { HomeIcon } from './HomeIcon';
export * from './Icon';
export { InheritanceIcon } from './InheritanceIcon';
+export { InProgressVisual } from './InProgressVisual';
export { IssueLocationIcon } from './IssueLocationIcon';
export { LinkIcon } from './LinkIcon';
export { LockIcon } from './LockIcon';
export { OpenCloseIndicator } from './OpenCloseIndicator';
export { OpenNewTabIcon } from './OpenNewTabIcon';
export { OverridenIcon } from './OverridenIcon';
+export { OverviewQGNotComputedIcon } from './OverviewQGNotComputedIcon';
export { OverviewQGPassedIcon } from './OverviewQGPassedIcon';
export { PencilIcon } from './PencilIcon';
export { PinIcon } from './PinIcon';
codeLineIssuePointerBorder: COLORS.white,
codeLineLocationHighlighted: [...COLORS.blueGrey[200], 0.6],
codeLineEllipsis: COLORS.white,
+ codeLineEllipsisContrast: COLORS.blueGrey[300],
codeLineEllipsisHover: secondary.light,
+ codeLineEllipsisHoverContrast: secondary.dark,
codeLineIssueLocation: [...danger.lighter, 0.15],
codeLineIssueLocationSelected: [...danger.lighter, 0.5],
codeLineIssueMessageTooltip: secondary.darker,
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { axiosToCatch } from '../helpers/request';
+import { SuggestedFix } from '../types/fix-suggestions';
+
+export interface FixParam {
+ issueId: string;
+}
+
+export function getSuggestions(data: FixParam): Promise<SuggestedFix> {
+ return axiosToCatch.post<SuggestedFix>('/api/v2/fix-suggestions/ai-suggestions', data);
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { cloneDeep } from 'lodash';
+import { FixParam, getSuggestions } from '../fix-suggestions';
+import { ISSUE_101 } from './data/ids';
+
+jest.mock('../fix-suggestions');
+
+export default class FixIssueServiceMock {
+ fixSuggestion = {
+ id: '70b14d4c-d302-4979-9121-06ac7d563c5c',
+ issueId: 'AYsVhClEbjXItrbcN71J',
+ explanation:
+ "Replaced 'require' statements with 'import' statements to comply with ECMAScript 2015 module management standards.",
+ changes: [
+ {
+ startLine: 6,
+ endLine: 7,
+ newCode: "import { glob } from 'glob';\nimport fs from 'fs';",
+ },
+ ],
+ };
+
+ constructor() {
+ jest.mocked(getSuggestions).mockImplementation(this.handleGetFixSuggestion);
+ }
+
+ handleGetFixSuggestion = (data: FixParam) => {
+ if (data.issueId === ISSUE_101) {
+ return Promise.reject({ error: { msg: 'Invalid issue' } });
+ }
+ return this.reply(this.fixSuggestion);
+ };
+
+ reply<T>(response: T): Promise<T> {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(cloneDeep(response));
+ }, 10);
+ });
+ }
+}
*/
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { range } from 'lodash';
import React from 'react';
import { byRole, byText } from '~sonar-aligned/helpers/testSelector';
+import { ISSUE_101 } from '../../../api/mocks/data/ids';
import { TabKeys } from '../../../components/rules/RuleTabViewer';
-import { mockLoggedInUser } from '../../../helpers/testMocks';
+import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
+import { Feature } from '../../../types/features';
import { RestUserDetailed } from '../../../types/users';
import {
branchHandler,
issuesHandler,
renderIssueApp,
renderProjectIssuesApp,
+ sourcesHandler,
ui,
usersHandler,
} from '../test-utils';
expect(ui.conciseIssueItem2.get()).toBeInTheDocument();
});
+ it('should be able to trigger a fix when feature is available', async () => {
+ sourcesHandler.setSource(
+ range(0, 20)
+ .map((n) => `line: ${n}`)
+ .join('\n'),
+ );
+ const user = userEvent.setup();
+ renderProjectIssuesApp(
+ 'project/issues?issueStatuses=CONFIRMED&open=issue2&id=myproject',
+ {},
+ mockLoggedInUser(),
+ [Feature.BranchSupport, Feature.FixSuggestions],
+ );
+
+ expect(await ui.getFixSuggestion.find()).toBeInTheDocument();
+ await user.click(ui.getFixSuggestion.get());
+
+ expect(await ui.suggestedExplanation.find()).toBeInTheDocument();
+
+ await user.click(ui.issueCodeTab.get());
+
+ expect(ui.seeFixSuggestion.get()).toBeInTheDocument();
+ });
+
+ it('should not be able to trigger a fix when user is not logged in', async () => {
+ renderProjectIssuesApp(
+ 'project/issues?issueStatuses=CONFIRMED&open=issue2&id=myproject',
+ {},
+ mockCurrentUser(),
+ [Feature.BranchSupport, Feature.FixSuggestions],
+ );
+ expect(await ui.issueCodeTab.find()).toBeInTheDocument();
+ expect(ui.getFixSuggestion.query()).not.toBeInTheDocument();
+ expect(ui.issueCodeFixTab.query()).not.toBeInTheDocument();
+ });
+
+ it('should show error when no fix is available', async () => {
+ const user = userEvent.setup();
+ renderProjectIssuesApp(
+ `project/issues?issueStatuses=CONFIRMED&open=${ISSUE_101}&id=myproject`,
+ {},
+ mockLoggedInUser(),
+ [Feature.BranchSupport, Feature.FixSuggestions],
+ );
+
+ await user.click(await ui.issueCodeFixTab.find());
+ await user.click(ui.getAFixSuggestion.get());
+
+ expect(await ui.noFixAvailable.find()).toBeInTheDocument();
+ });
+
it('should navigate to Why is this an issue tab', async () => {
renderProjectIssuesApp('project/issues?issues=issue2&open=issue2&id=myproject&why=1');
WithIndexationContextProps,
} from '../../../components/hoc/withIndexationContext';
import withIndexationGuard from '../../../components/hoc/withIndexationGuard';
+import { IssueSuggestionCodeTab } from '../../../components/rules/IssueSuggestionCodeTab';
import IssueTabViewer from '../../../components/rules/IssueTabViewer';
import '../../../components/search-navigator.css';
import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils';
selectedLocationIndex={this.state.selectedLocationIndex}
/>
}
+ suggestionTabContent={
+ <IssueSuggestionCodeTab
+ branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
+ issue={openIssue}
+ language={openRuleDetails.lang}
+ />
+ }
extendedDescription={openRuleDetails.htmlNote}
issue={openIssue}
onIssueChange={this.handleIssueChange}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import styled from '@emotion/styled';
+import { Button, ButtonVariety, Spinner } from '@sonarsource/echoes-react';
import classNames from 'classnames';
import { FlagMessage, IssueMessageHighlighting, LineFinding, themeColor } from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
import { getSources } from '../../../api/components';
+import { AvailableFeaturesContext } from '../../../app/components/available-features/AvailableFeaturesContext';
+import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
+import { TabKeys } from '../../../components/rules/IssueTabViewer';
+import { TabSelectorContext } from '../../../components/rules/TabSelectorContext';
import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
import { translate } from '../../../helpers/l10n';
+import {
+ usePrefetchSuggestion,
+ useUnifiedSuggestionsQuery,
+} from '../../../queries/fix-suggestions';
import { BranchLike } from '../../../types/branch-like';
import { isFile } from '../../../types/component';
+import { Feature } from '../../../types/features';
import { IssueDeprecatedStatus } from '../../../types/issues';
import {
Dict,
Duplication,
ExpandDirection,
FlowLocation,
+ Issue,
IssuesByLine,
Snippet,
SnippetGroup,
SourceViewerFile,
Issue as TypeIssue,
} from '../../../types/types';
+import { CurrentUser, isLoggedIn } from '../../../types/users';
import { IssueSourceViewerScrollContext } from '../components/IssueSourceViewerScrollContext';
import { IssueSourceViewerHeader } from './IssueSourceViewerHeader';
import SnippetViewer from './SnippetViewer';
interface Props {
branchLike: BranchLike | undefined;
+ currentUser: CurrentUser;
duplications?: Duplication[];
duplicationsByLine?: { [line: number]: number[] };
highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
snippets: Snippet[];
}
-export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<
- Readonly<Props>,
- State
-> {
+class ComponentSourceSnippetGroupViewer extends React.PureComponent<Readonly<Props>, State> {
mounted = false;
constructor(props: Readonly<Props>) {
};
renderIssuesList = (line: SourceLine) => {
- const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
+ const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup, currentUser } =
+ this.props;
const locations =
issue.component === snippetGroup.component.key && issue.textRange !== undefined
? locationsByLine([issue])
<IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
{(ctx) => (
<LineFinding
+ as={isSelectedIssue ? 'div' : undefined}
+ className="sw-justify-between"
issueKey={issueToDisplay.key}
message={
<IssueMessageHighlighting
selected={isSelectedIssue}
ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
onIssueSelect={this.props.onIssueSelect}
+ getFixButton={
+ isSelectedIssue ? (
+ <GetFixButton issue={issueToDisplay} currentUser={currentUser} />
+ ) : undefined
+ }
/>
)}
</IssueSourceViewerScrollContext.Consumer>
const FileLevelIssueStyle = styled.div`
border: 1px solid ${themeColor('codeLineBorder')};
`;
+
+function GetFixButton({
+ currentUser,
+ issue,
+}: Readonly<{ currentUser: CurrentUser; issue: Issue }>) {
+ const handler = React.useContext(TabSelectorContext);
+ const { data: suggestion, isLoading } = useUnifiedSuggestionsQuery(issue, false);
+ const prefetchSuggestion = usePrefetchSuggestion(issue.key);
+
+ const isSuggestionFeatureEnabled = React.useContext(AvailableFeaturesContext).includes(
+ Feature.FixSuggestions,
+ );
+
+ if (!isLoggedIn(currentUser) || !isSuggestionFeatureEnabled) {
+ return null;
+ }
+ return (
+ <Spinner ariaLabel={translate('issues.code_fix.fix_is_being_generated')} isLoading={isLoading}>
+ {suggestion !== undefined && (
+ <Button
+ className="sw-shrink-0"
+ onClick={() => {
+ handler(TabKeys.CodeFix);
+ }}
+ >
+ {translate('issues.code_fix.see_fix_suggestion')}
+ </Button>
+ )}
+ {suggestion === undefined && (
+ <Button
+ className="sw-ml-2 sw-shrink-0"
+ onClick={() => {
+ handler(TabKeys.CodeFix);
+ prefetchSuggestion();
+ }}
+ variety={ButtonVariety.Primary}
+ >
+ {translate('issues.code_fix.get_fix_suggestion')}
+ </Button>
+ )}
+ </Spinner>
+ );
+}
+
+export default withCurrentUserContext(ComponentSourceSnippetGroupViewer);
linkToProject?: boolean;
loading?: boolean;
onExpand?: () => void;
+ shouldShowOpenInIde?: boolean;
+ shouldShowViewAllIssues?: boolean;
sourceViewerFile: SourceViewerFile;
}
loading,
onExpand,
sourceViewerFile,
+ shouldShowOpenInIde = true,
+ shouldShowViewAllIssues = true,
} = props;
const { measures, path, project, projectName, q } = sourceViewerFile;
)}
</div>
- {!isProjectRoot && isLoggedIn(currentUser) && !isLoadingBranches && (
+ {!isProjectRoot && shouldShowOpenInIde && isLoggedIn(currentUser) && !isLoadingBranches && (
<IssueOpenInIdeButton
branchName={branchName}
issueKey={issueKey}
/>
)}
- {!isProjectRoot && measures.issues !== undefined && (
+ {!isProjectRoot && shouldShowViewAllIssues && measures.issues !== undefined && (
<div
className={classNames('sw-ml-4', {
'sw-mr-1': (!expandable || loading) ?? isLoadingBranches,
import { byPlaceholderText, byRole, byTestId, byText } from '~sonar-aligned/helpers/testSelector';
import BranchesServiceMock from '../../api/mocks/BranchesServiceMock';
import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock';
+import FixIssueServiceMock from '../../api/mocks/FixIssueServiceMock';
import IssuesServiceMock from '../../api/mocks/IssuesServiceMock';
import SourcesServiceMock from '../../api/mocks/SourcesServiceMock';
import UsersServiceMock from '../../api/mocks/UsersServiceMock';
export const componentsHandler = new ComponentsServiceMock();
export const sourcesHandler = new SourcesServiceMock();
export const branchHandler = new BranchesServiceMock();
+export const fixIssueHanlder = new FixIssueServiceMock();
export const ui = {
loading: byText('issues.loading_issues'),
+ fixGenerated: byText('issues.code_fix.fix_is_being_generated'),
+ noFixAvailable: byText('issues.code_fix.something_went_wrong'),
+ suggestedExplanation: byText(fixIssueHanlder.fixSuggestion.explanation),
issuePageHeadering: byRole('heading', { level: 1, name: 'issues.page' }),
issueItemAction1: byRole('link', { name: 'Issue with no location message' }),
issueItemAction2: byRole('link', { name: 'FlowIssue' }),
issueStatusFacet: byRole('button', { name: 'issues.facet.issueStatuses' }),
tagFacet: byRole('button', { name: 'issues.facet.tags' }),
typeFacet: byRole('button', { name: 'issues.facet.types' }),
+ getFixSuggestion: byRole('button', { name: 'issues.code_fix.get_fix_suggestion' }),
+ getAFixSuggestion: byRole('button', { name: 'issues.code_fix.get_a_fix_suggestion' }),
+
+ seeFixSuggestion: byRole('button', { name: 'issues.code_fix.see_fix_suggestion' }),
cleanCodeAttributeCategoryFacet: byRole('button', {
name: 'issues.facet.cleanCodeAttributeCategories',
}),
ruleFacetSearch: byPlaceholderText('search.search_for_rules'),
tagFacetSearch: byPlaceholderText('search.search_for_tags'),
+ issueCodeFixTab: byRole('tab', { name: 'coding_rules.description_section.title.code_fix' }),
+ issueCodeTab: byRole('tab', { name: 'issue.tabs.code' }),
issueActivityTab: byRole('tab', { name: 'coding_rules.description_section.title.activity' }),
issueActivityAddComment: byRole('button', {
name: `issue.activity.add_comment`,
[NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true,
},
}),
+ featureList = [Feature.BranchSupport],
) {
renderAppWithComponentContext(
'project/issues',
{projectIssuesRoutes()}
</Route>
),
- { navigateTo, currentUser, featureList: [Feature.BranchSupport] },
+ { navigateTo, currentUser, featureList },
{ component: mockComponent(overrides) },
);
}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { Button, ButtonVariety } from '@sonarsource/echoes-react';
+import { InProgressVisual, OverviewQGNotComputedIcon, OverviewQGPassedIcon } from 'design-system';
+import React from 'react';
+import { translate } from '../../helpers/l10n';
+import { usePrefetchSuggestion, useUnifiedSuggestionsQuery } from '../../queries/fix-suggestions';
+import { useRawSourceQuery } from '../../queries/sources';
+import { getBranchLikeQuery } from '../../sonar-aligned/helpers/branch-like';
+import { BranchLike } from '../../types/branch-like';
+import { Issue } from '../../types/types';
+import { IssueSuggestionFileSnippet } from './IssueSuggestionFileSnippet';
+
+interface Props {
+ branchLike?: BranchLike;
+ issue: Issue;
+ language?: string;
+}
+
+export function IssueSuggestionCodeTab({ branchLike, issue, language }: Readonly<Props>) {
+ const prefetchSuggestion = usePrefetchSuggestion(issue.key);
+ const { isPending, isLoading, isError, refetch } = useUnifiedSuggestionsQuery(issue, false);
+ const { isError: isIssueRawError } = useRawSourceQuery({
+ ...getBranchLikeQuery(branchLike),
+ key: issue.component,
+ });
+
+ return (
+ <>
+ {isPending && !isLoading && !isError && (
+ <div className="sw-flex sw-flex-col sw-items-center">
+ <OverviewQGPassedIcon className="sw-mt-6" />
+ <p className="sw-body-sm-highlight sw-mt-4">
+ {translate('issues.code_fix.let_us_suggest_fix')}
+ </p>
+ <Button
+ className="sw-mt-4"
+ onClick={() => prefetchSuggestion()}
+ variety={ButtonVariety.Primary}
+ >
+ {translate('issues.code_fix.get_a_fix_suggestion')}
+ </Button>
+ </div>
+ )}
+ {isLoading && (
+ <div className="sw-flex sw-pt-6 sw-flex-col sw-items-center">
+ <InProgressVisual />
+ <p className="sw-body-sm-highlight sw-mt-4">
+ {translate('issues.code_fix.fix_is_being_generated')}
+ </p>
+ </div>
+ )}
+ {isError && (
+ <div className="sw-flex sw-flex-col sw-items-center">
+ <OverviewQGNotComputedIcon className="sw-mt-6" />
+ <p className="sw-body-sm-highlight sw-mt-4">
+ {translate('issues.code_fix.something_went_wrong')}
+ </p>
+ <p className="sw-my-4">{translate('issues.code_fix.not_able_to_generate_fix')}</p>
+ {translate('issues.code_fix.check_how_to_fix')}
+ {!isIssueRawError && (
+ <Button className="sw-mt-4" onClick={() => refetch()} variety={ButtonVariety.Primary}>
+ {translate('issues.code_fix.get_a_fix_suggestion')}
+ </Button>
+ )}
+ </div>
+ )}
+
+ {!isPending && !isError && (
+ <IssueSuggestionFileSnippet branchLike={branchLike} issue={issue} language={language} />
+ )}
+ </>
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { max } from 'lodash';
+import React, { Fragment, useCallback, useEffect, useState } from 'react';
+
+import {
+ ClipboardIconButton,
+ CodeEllipsisDirection,
+ CodeEllipsisIcon,
+ LineCodeEllipsisStyled,
+ SonarCodeColorizer,
+ themeColor,
+} from 'design-system';
+import { IssueSourceViewerHeader } from '../../apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader';
+import { translate } from '../../helpers/l10n';
+import { useComponentForSourceViewer } from '../../queries/component';
+import {
+ DisplayedLine,
+ LineTypeEnum,
+ useUnifiedSuggestionsQuery,
+} from '../../queries/fix-suggestions';
+import { BranchLike } from '../../types/branch-like';
+import { Issue } from '../../types/types';
+import { IssueSuggestionLine } from './IssueSuggestionLine';
+
+interface Props {
+ branchLike?: BranchLike;
+ issue: Issue;
+ language?: string;
+}
+const EXPAND_SIZE = 10;
+const BUFFER_CODE = 3;
+
+export function IssueSuggestionFileSnippet({ branchLike, issue, language }: Readonly<Props>) {
+ const [displayedLine, setDisplayedLine] = useState<DisplayedLine[]>([]);
+
+ const { data: suggestion } = useUnifiedSuggestionsQuery(issue);
+
+ const { data: sourceViewerFile } = useComponentForSourceViewer(issue.component, branchLike);
+
+ useEffect(() => {
+ if (suggestion !== undefined) {
+ setDisplayedLine(
+ suggestion.unifiedLines.filter((line, index) => {
+ if (line.type !== LineTypeEnum.CODE) {
+ return true;
+ }
+ return suggestion.unifiedLines
+ .slice(max([index - BUFFER_CODE, 0]), index + BUFFER_CODE + 1)
+ .some((line) => line.type !== LineTypeEnum.CODE);
+ }),
+ );
+ }
+ }, [suggestion]);
+
+ const handleExpand = useCallback(
+ (index: number | undefined, at: number | undefined, to: number) => {
+ if (suggestion !== undefined) {
+ setDisplayedLine((current) => {
+ return [
+ ...current.slice(0, index),
+ ...suggestion.unifiedLines.filter(
+ (line) => at !== undefined && at <= line.lineBefore && line.lineBefore < to,
+ ),
+ ...current.slice(index),
+ ];
+ });
+ }
+ },
+ [suggestion],
+ );
+
+ if (suggestion === undefined) {
+ return null;
+ }
+
+ return (
+ <div>
+ {sourceViewerFile && (
+ <IssueSourceViewerHeader
+ issueKey={issue.key}
+ sourceViewerFile={sourceViewerFile}
+ shouldShowOpenInIde={false}
+ shouldShowViewAllIssues={false}
+ />
+ )}
+ <SourceFileWrapper className="js-source-file sw-mb-4">
+ <SonarCodeColorizer>
+ {displayedLine[0]?.lineBefore !== 1 && (
+ <LineCodeEllipsisStyled
+ onClick={() =>
+ handleExpand(
+ 0,
+ max([displayedLine[0].lineBefore - EXPAND_SIZE, 0]),
+ displayedLine[0].lineBefore,
+ )
+ }
+ style={{ borderTop: 'none' }}
+ >
+ <CodeEllipsisIcon direction={CodeEllipsisDirection.Up} />
+ </LineCodeEllipsisStyled>
+ )}
+ {displayedLine.map((line, index) => (
+ <Fragment key={`${line.lineBefore} -> ${line.lineAfter} `}>
+ {displayedLine[index - 1] !== undefined &&
+ displayedLine[index - 1].lineBefore !== -1 &&
+ line.lineBefore !== -1 &&
+ displayedLine[index - 1].lineBefore !== line.lineBefore - 1 && (
+ <>
+ {line.lineBefore - displayedLine[index - 1].lineBefore > EXPAND_SIZE ? (
+ <>
+ <LineCodeEllipsisStyled
+ onClick={() =>
+ handleExpand(
+ index,
+ displayedLine[index - 1].lineBefore + 1,
+ displayedLine[index - 1].lineBefore + EXPAND_SIZE + 1,
+ )
+ }
+ >
+ <CodeEllipsisIcon direction={CodeEllipsisDirection.Down} />
+ </LineCodeEllipsisStyled>
+ <LineCodeEllipsisStyled
+ onClick={() =>
+ handleExpand(index, line.lineBefore - EXPAND_SIZE, line.lineBefore)
+ }
+ style={{ borderTop: 'none' }}
+ >
+ <CodeEllipsisIcon direction={CodeEllipsisDirection.Up} />
+ </LineCodeEllipsisStyled>
+ </>
+ ) : (
+ <LineCodeEllipsisStyled
+ onClick={() =>
+ handleExpand(
+ index,
+ displayedLine[index - 1].lineBefore + 1,
+ line.lineBefore,
+ )
+ }
+ >
+ <CodeEllipsisIcon direction={CodeEllipsisDirection.Middle} />
+ </LineCodeEllipsisStyled>
+ )}
+ </>
+ )}
+ <div className="sw-relative">
+ {line.copy !== undefined && (
+ <StyledClipboardIconButton
+ aria-label={translate('component_viewer.copy_path_to_clipboard')}
+ copyValue={line.copy}
+ />
+ )}
+ <IssueSuggestionLine
+ language={language}
+ line={line.code}
+ lineAfter={line.lineAfter}
+ lineBefore={line.lineBefore}
+ type={line.type}
+ />
+ </div>
+ </Fragment>
+ ))}
+
+ {displayedLine[displayedLine.length - 1]?.lineBefore !==
+ suggestion.unifiedLines[suggestion.unifiedLines.length - 1]?.lineBefore && (
+ <LineCodeEllipsisStyled
+ onClick={() =>
+ handleExpand(
+ displayedLine.length,
+ displayedLine[displayedLine.length - 1].lineBefore + 1,
+ displayedLine[displayedLine.length - 1].lineBefore + EXPAND_SIZE + 1,
+ )
+ }
+ style={{ borderBottom: 'none' }}
+ >
+ <CodeEllipsisIcon direction={CodeEllipsisDirection.Down} />
+ </LineCodeEllipsisStyled>
+ )}
+ </SonarCodeColorizer>
+ </SourceFileWrapper>
+ <p className="sw-mt-4">{suggestion.explanation}</p>
+ </div>
+ );
+}
+
+const StyledClipboardIconButton = styled(ClipboardIconButton)`
+ position: absolute;
+ right: 4px;
+ top: -4px;
+ z-index: 9;
+`;
+
+const SourceFileWrapper = styled.div`
+ border: 1px solid ${themeColor('codeLineBorder')};
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 {
+ CodeSyntaxHighlighter,
+ LineMeta,
+ LineStyled,
+ SuggestedLineWrapper,
+ themeBorder,
+ themeColor,
+} from 'design-system';
+import React from 'react';
+import { LineTypeEnum } from '../../queries/fix-suggestions';
+
+type LineType = 'code' | 'added' | 'removed';
+
+export function IssueSuggestionLine({
+ language,
+ line,
+ lineAfter,
+ lineBefore,
+ type = 'code',
+}: Readonly<{
+ language?: string;
+ line: string;
+ lineAfter: number;
+ lineBefore: number;
+ type: LineType;
+}>) {
+ return (
+ <SuggestedLineWrapper>
+ <LineMeta as="div">
+ {type !== LineTypeEnum.ADDED && (
+ <LineNumberStyled className="sw-px-1 sw-inline-block">{lineBefore}</LineNumberStyled>
+ )}
+ </LineMeta>
+ <LineMeta as="div">
+ {type !== LineTypeEnum.REMOVED && (
+ <LineNumberStyled className="sw-px-1 sw-inline-block">{lineAfter}</LineNumberStyled>
+ )}
+ </LineMeta>
+ <LineDirectionMeta as="div">
+ {type === LineTypeEnum.REMOVED && (
+ <RemovedLineLayer className="sw-px-2">-</RemovedLineLayer>
+ )}
+ {type === LineTypeEnum.ADDED && <AddedLineLayer className="sw-px-2">+</AddedLineLayer>}
+ </LineDirectionMeta>
+ <LineCodeLayers>
+ {type === LineTypeEnum.CODE && (
+ <LineCodeLayer className="sw-px-3">
+ <CodeSyntaxHighlighter
+ htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`}
+ language={language}
+ escapeDom={false}
+ />
+ </LineCodeLayer>
+ )}
+ {type === LineTypeEnum.REMOVED && (
+ <RemovedLineLayer className="sw-px-3">
+ <LineCodePreFormatted>
+ <CodeSyntaxHighlighter
+ htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`}
+ language={language}
+ escapeDom={false}
+ />
+ </LineCodePreFormatted>
+ </RemovedLineLayer>
+ )}
+ {type === LineTypeEnum.ADDED && (
+ <AddedLineLayer className="sw-px-3">
+ <LineCodePreFormatted>
+ <CodeSyntaxHighlighter
+ htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`}
+ language={language}
+ escapeDom={false}
+ />
+ </LineCodePreFormatted>
+ </AddedLineLayer>
+ )}
+ </LineCodeLayers>
+ </SuggestedLineWrapper>
+ );
+}
+
+const LineNumberStyled = styled.div`
+ &:hover {
+ color: ${themeColor('codeLineMetaHover')};
+ }
+
+ &:focus-visible {
+ outline-offset: -1px;
+ }
+`;
+
+const LineCodeLayers = styled.div`
+ position: relative;
+ display: grid;
+ height: 100%;
+ background-color: var(--line-background);
+
+ ${LineStyled}:hover & {
+ background-color: ${themeColor('codeLineHover')};
+ }
+`;
+
+const LineDirectionMeta = styled(LineMeta)`
+ border-left: ${themeBorder('default', 'codeLineBorder')};
+`;
+
+const LineCodeLayer = styled.div`
+ grid-row: 1;
+ grid-column: 1;
+`;
+
+const LineCodePreFormatted = styled.pre`
+ position: relative;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ tab-size: 4;
+`;
+
+const AddedLineLayer = styled.div`
+ background-color: ${themeColor('codeLineCoveredUnderline')};
+`;
+
+const RemovedLineLayer = styled.div`
+ background-color: ${themeColor('codeLineUncoveredUnderline')};
+`;
import * as React from 'react';
import { Location } from 'react-router-dom';
import { dismissNotice } from '../../api/users';
+import withAvailableFeatures from '../../app/components/available-features/withAvailableFeatures';
import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext';
import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
import StyledHeader from '../../apps/issues/components/StyledHeader';
import { fillBranchLike } from '../../helpers/branch-like';
import { translate } from '../../helpers/l10n';
+import { Feature } from '../../types/features';
import { Issue, RuleDetails } from '../../types/types';
-import { NoticeType } from '../../types/users';
+import { CurrentUser, NoticeType } from '../../types/users';
import ScreenPositionHelper from '../common/ScreenPositionHelper';
import withLocation from '../hoc/withLocation';
import MoreInfoRuleDescription from './MoreInfoRuleDescription';
import RuleDescription from './RuleDescription';
+import { TabSelectorContext } from './TabSelectorContext';
interface IssueTabViewerProps extends CurrentUserContextInterface {
activityTabContent?: React.ReactNode;
codeTabContent?: React.ReactNode;
+ currentUser: CurrentUser;
extendedDescription?: string;
+ hasFeature: (feature: string) => boolean;
issue: Issue;
location: Location;
onIssueChange: (issue: Issue) => void;
ruleDetails: RuleDetails;
selectedFlowIndex?: number;
selectedLocationIndex?: number;
+ suggestionTabContent?: React.ReactNode;
}
interface State {
displayEducationalPrinciplesNotification?: boolean;
WhyIsThisAnIssue = 'why',
HowToFixIt = 'how_to_fix',
AssessTheIssue = 'assess_the_problem',
+ CodeFix = 'code_fix',
Activity = 'activity',
MoreInfo = 'more_info',
}
prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey ||
prevProps.issue !== issue ||
prevProps.selectedFlowIndex !== selectedFlowIndex ||
- prevProps.selectedLocationIndex !== selectedLocationIndex ||
+ (prevProps.selectedLocationIndex ?? -1) !== (selectedLocationIndex ?? -1) ||
prevProps.currentUser !== currentUser
) {
this.setState((pState) =>
const tabs = this.computeTabs(displayEducationalPrinciplesNotification);
+ const selectedTab =
+ resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab;
+
return {
tabs,
- selectedTab: resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab,
+ selectedTab,
displayEducationalPrinciplesNotification,
};
};
computeTabs = (displayEducationalPrinciplesNotification: boolean) => {
const {
codeTabContent,
+ currentUser: { isLoggedIn },
ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType },
ruleDescriptionContextKey,
extendedDescription,
activityTabContent,
issue,
+ suggestionTabContent,
+ hasFeature,
} = this.props;
// As we might tamper with the description later on, we clone to avoid any side effect
/>
),
},
+ ...(hasFeature(Feature.FixSuggestions) && isLoggedIn
+ ? [
+ {
+ value: TabKeys.CodeFix,
+ key: TabKeys.CodeFix,
+ label: translate('coding_rules.description_section.title', TabKeys.CodeFix),
+ content: suggestionTabContent,
+ },
+ ]
+ : []),
{
value: TabKeys.Activity,
key: TabKeys.Activity,
};
handleSelectTabs = (currentTabKey: TabKeys) => {
- this.setState(({ tabs }) => ({
- selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0],
- }));
+ this.setState(({ tabs }) => {
+ return {
+ selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0],
+ };
+ });
};
render() {
})}
key={tab.key}
>
- {tab.content}
+ <TabSelectorContext.Provider value={this.handleSelectTabs}>
+ {tab.content}
+ </TabSelectorContext.Provider>
</div>
))
}
}
}
-export default withCurrentUserContext(withLocation(IssueTabViewer));
+export default withCurrentUserContext(withLocation(withAvailableFeatures(IssueTabViewer)));
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { noop } from 'lodash';
+import { createContext } from 'react';
+import { TabKeys } from './IssueTabViewer';
+
+export const TabSelectorContext = createContext<(selectedTab: TabKeys) => void>(noop);
import { groupBy, omit } from 'lodash';
import { BranchParameters } from '~sonar-aligned/types/branch-like';
import { getTasksForComponent } from '../api/ce';
-import { getBreadcrumbs, getComponent, getComponentData } from '../api/components';
+import {
+ getBreadcrumbs,
+ getComponent,
+ getComponentData,
+ getComponentForSourceViewer,
+} from '../api/components';
+import { getBranchLikeQuery } from '../sonar-aligned/helpers/branch-like';
+import { BranchLike } from '../types/branch-like';
import { Component, Measure } from '../types/types';
import { StaleTime, createQueryHook } from './common';
});
},
);
+
+export function useComponentForSourceViewer(fileKey: string, branchLike?: BranchLike) {
+ return useQuery({
+ queryKey: ['component', 'source-viewer', fileKey, branchLike] as const,
+ queryFn: ({ queryKey: [_1, _2, fileKey, branchLike] }) =>
+ getComponentForSourceViewer({ component: fileKey, ...getBranchLikeQuery(branchLike) }),
+ staleTime: Infinity,
+ });
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { useQuery, useQueryClient } from '@tanstack/react-query';
+import { some } from 'lodash';
+import { getSuggestions } from '../api/fix-suggestions';
+import { Issue } from '../types/types';
+import { useRawSourceQuery } from './sources';
+
+const UNKNOWN = -1;
+
+export enum LineTypeEnum {
+ CODE = 'code',
+ ADDED = 'added',
+ REMOVED = 'removed',
+}
+
+export type DisplayedLine = {
+ code: string;
+ copy?: string;
+ lineAfter: number;
+ lineBefore: number;
+ type: LineTypeEnum;
+};
+
+export type CodeSuggestion = {
+ changes: Array<{ endLine: number; newCode: string; startLine: number }>;
+ explanation: string;
+ suggestionId: string;
+ unifiedLines: DisplayedLine[];
+};
+
+export function usePrefetchSuggestion(issueKey: string) {
+ const queryClient = useQueryClient();
+ return () => {
+ queryClient.prefetchQuery({ queryKey: ['code-suggestions', issueKey] });
+ };
+}
+
+export function useUnifiedSuggestionsQuery(issue: Issue, enabled = true) {
+ const branchLikeParam = issue.pullRequest
+ ? { pullRequest: issue.pullRequest }
+ : issue.branch
+ ? { branch: issue.branch }
+ : {};
+
+ const { data: code } = useRawSourceQuery(
+ { ...branchLikeParam, key: issue.component },
+ { enabled },
+ );
+
+ return useQuery({
+ queryKey: ['code-suggestions', issue.key],
+ queryFn: ({ queryKey: [_1, issueId] }) => getSuggestions({ issueId }),
+ enabled: enabled && code !== undefined,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ staleTime: Infinity,
+ retry: false,
+ select: (suggestedCode) => {
+ if (code !== undefined && suggestedCode.changes) {
+ const originalCodes = code.split(/\r?\n|\r|\n/g).map((line, index) => {
+ const lineNumber = index + 1;
+ const isRemoved = some(
+ suggestedCode.changes,
+ ({ startLine, endLine }) => startLine <= lineNumber && lineNumber <= endLine,
+ );
+ return {
+ code: line,
+ lineNumber,
+ type: isRemoved ? LineTypeEnum.REMOVED : LineTypeEnum.CODE,
+ };
+ });
+
+ const unifiedLines = originalCodes.flatMap<DisplayedLine>((line) => {
+ const change = suggestedCode.changes.find(
+ ({ endLine }) => endLine === line.lineNumber - 1,
+ );
+ if (change) {
+ return [
+ ...change.newCode.split(/\r?\n|\r|\n/g).map((newLine, index) => ({
+ code: newLine,
+ type: LineTypeEnum.ADDED,
+ lineBefore: UNKNOWN,
+ lineAfter: UNKNOWN,
+ copy: index === 0 ? change.newCode : undefined,
+ })),
+ { code: line.code, type: line.type, lineBefore: line.lineNumber, lineAfter: UNKNOWN },
+ ];
+ }
+
+ return [
+ { code: line.code, type: line.type, lineBefore: line.lineNumber, lineAfter: UNKNOWN },
+ ];
+ });
+ let lineAfterCount = 1;
+ unifiedLines.forEach((line) => {
+ if (line.type !== LineTypeEnum.REMOVED) {
+ line.lineAfter = lineAfterCount;
+ lineAfterCount += 1;
+ }
+ });
+ return {
+ unifiedLines,
+ explanation: suggestedCode.explanation,
+ changes: suggestedCode.changes,
+ suggestionId: suggestedCode.id,
+ };
+ }
+ return {
+ unifiedLines: [],
+ explanation: suggestedCode.explanation,
+ changes: [],
+ suggestionId: suggestedCode.id,
+ };
+ },
+ });
+}
GithubProvisioning = 'github-provisioning',
GitlabProvisioning = 'gitlab-provisioning',
PrioritizedRules = 'prioritized-rules',
+ FixSuggestions = 'fix-suggestions',
}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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.
+ */
+interface SuggestedChange {
+ endLine: number;
+ newCode: string;
+ startLine: number;
+}
+
+export interface SuggestedFix {
+ changes: SuggestedChange[];
+ explanation: string;
+ id: string;
+ issueId: string;
+}
issue.unnamed_location=Other location
issue.show_full_execution_flow=See the whole {0} step execution flow
+
+# Issues code fix
+issues.code_fix.get_fix_suggestion= Generate AI Fix
+issues.code_fix.see_fix_suggestion= See AI Fix
+issues.code_fix.get_a_fix_suggestion= Generate Fix
+issues.code_fix.let_us_suggest_fix= Let us suggest a fix for this issue
+issues.code_fix.fix_is_being_generated= A fix is being generated...
+issues.code_fix.something_went_wrong= Something went wrong.
+issues.code_fix.not_able_to_generate_fix= We are not able to generate a fix for this issue.
+issues.code_fix.check_how_to_fix= Try again later, or visit the other sections above to learn how to fix this issue.
+
#------------------------------------------------------------------------------
#
# ISSUE CHANGELOG
coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT=What is the risk?
coding_rules.description_section.title.assess_the_problem=Assess the risk
coding_rules.description_section.title.how_to_fix=How can I fix it?
+coding_rules.description_section.title.code_fix=AI CodeFix
coding_rules.description_section.title.more_info=More info
coding_rules.description_section.title.activity=Activity