]> source.dussan.org Git - sonarqube.git/commitdiff
CODEFIX-12 Show new suggestion feature in issues page
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>
Wed, 28 Aug 2024 14:40:16 +0000 (16:40 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 4 Sep 2024 20:03:11 +0000 (20:03 +0000)
30 files changed:
server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx
server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx
server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap
server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap
server/sonar-web/design-system/src/components/code-line/LineFinding.tsx
server/sonar-web/design-system/src/components/code-line/LineStyles.tsx
server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx
server/sonar-web/design-system/src/components/icons/CodeEllipsisIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/InProgressVisual.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/OverviewQGNotComputedIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/api/fix-suggestions.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx
server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts [new file with mode: 0644]
server/sonar-web/src/main/js/queries/component.ts
server/sonar-web/src/main/js/queries/fix-suggestions.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/features.ts
server/sonar-web/src/main/js/types/fix-suggestions.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 100bcbe6ee88a68a4e14b8295db664eeb64b64cc..57cd229e71f1752708bf67e27dd0256c60b0cd80 100644 (file)
@@ -44,6 +44,7 @@ hljs.addPlugin(hljsUnderlinePlugin);
 
 interface Props {
   className?: string;
+  escapeDom?: boolean;
   htmlAsString: string;
   language?: string;
   wrap?: boolean | 'words';
@@ -60,15 +61,14 @@ const htmlDecode = (escapedCode: string) => {
 };
 
 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 {
index 20095ec4b94da0ac7b5f73a16ecfcb1378b43d22..038ae20ae92d4eefeeea7be4f3916d3cde80be7b 100644 (file)
@@ -31,7 +31,7 @@ it('should render correctly as button', async () => {
 });
 
 it('should render as non-button', () => {
-  setupWithProps({ as: 'div' });
+  setupWithProps({ as: 'div', onIssueSelect: undefined });
   expect(screen.queryByRole('button')).not.toBeInTheDocument();
 });
 
index 03bb657177e1adf6f924632303768006f279b41f..32f97a32f8ecfd73f954f7d9d4b5871d46dc2827 100644 (file)
@@ -19,7 +19,7 @@
  */
 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();
@@ -43,6 +43,15 @@ it('should set a highlighted background color in css props', () => {
   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} />,
index 5423d1645fff5492e0d6cb4436fa84f349130c99..0c1e1918e4f139dbe4e5296dc05ccee2769b6367 100644 (file)
@@ -14,7 +14,7 @@ exports[`should render correctly when covered 1`] = `
   user-select: none;
 }
 
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
   background-color: rgb(239,242,249);
 }
 
@@ -58,7 +58,7 @@ exports[`should render correctly when no data 1`] = `
   user-select: none;
 }
 
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
   background-color: rgb(239,242,249);
 }
 
@@ -84,7 +84,7 @@ exports[`should render correctly when partially covered with 5/10 conditions 1`]
   user-select: none;
 }
 
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
   background-color: rgb(239,242,249);
 }
 
@@ -145,7 +145,7 @@ exports[`should render correctly when partially covered without conditions 1`] =
   user-select: none;
 }
 
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
   background-color: rgb(239,242,249);
 }
 
@@ -206,7 +206,7 @@ exports[`should render correctly when uncovered 1`] = `
   user-select: none;
 }
 
-.e97pm2l12:hover .emotion-0 {
+.e97pm2l13:hover .emotion-0 {
   background-color: rgb(239,242,249);
 }
 
index d4ef5c08bf2376fdba02da072ec9dd0e962da940..4b2719e5a41e2f9912370b272681ff2a61c9ef6a 100644 (file)
@@ -28,6 +28,7 @@ exports[`should render correctly as button 1`] = `
   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;
@@ -42,12 +43,25 @@ exports[`should render correctly as button 1`] = `
   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>
 `;
index 5169279dacfd49619dfaa7013a30120e701e4897..3d30a04bb255f9e2fcd98cf2dee2a58ee42b1808 100644 (file)
@@ -26,6 +26,7 @@ import { BareButton } from '../../sonar-aligned/components/buttons';
 interface Props {
   as?: React.ElementType;
   className?: string;
+  getFixButton?: React.ReactNode;
   issueKey: string;
   message: React.ReactNode;
   onIssueSelect?: (issueKey: string) => void;
@@ -33,10 +34,31 @@ interface Props {
 }
 
 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}
@@ -65,6 +87,7 @@ const LineFindingStyled = styled(BareButton)<{ selected: boolean }>`
   ${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
index 82d2ccea7d88031c9b4404cc7f3847e7d2491b07..c576954ab47e8f50b7f97662d63c9cfcc840c339 100644 (file)
@@ -20,6 +20,7 @@
 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')};
@@ -143,3 +144,21 @@ export const UncoveredUnderlineLabel = styled(UnderlineLabel)`
   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')};
+  }
+`;
index 34b78b601fadec01b7337935051ee25425f1eeb5..1d666acd77ad833bb97c42f5ea72b6158f6ba64b 100644 (file)
@@ -47,3 +47,17 @@ export function LineWrapper(props: Props) {
     />
   );
 }
+
+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}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/CodeEllipsisIcon.tsx b/server/sonar-web/design-system/src/components/icons/CodeEllipsisIcon.tsx
new file mode 100644 (file)
index 0000000..36a3df4
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * 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} />;
+}
diff --git a/server/sonar-web/design-system/src/components/icons/InProgressVisual.tsx b/server/sonar-web/design-system/src/components/icons/InProgressVisual.tsx
new file mode 100644 (file)
index 0000000..f385357
--- /dev/null
@@ -0,0 +1,106 @@
+/*
+ * 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;
+`;
diff --git a/server/sonar-web/design-system/src/components/icons/OverviewQGNotComputedIcon.tsx b/server/sonar-web/design-system/src/components/icons/OverviewQGNotComputedIcon.tsx
new file mode 100644 (file)
index 0000000..aa7ba5e
--- /dev/null
@@ -0,0 +1,135 @@
+/*
+ * 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>
+  );
+}
index deaf64a2f59d5664ac405b657e155f9622f14650..1cccc42ba0c68414d6d274f90bf35648ea83ba9a 100644 (file)
@@ -28,6 +28,7 @@ export { ChevronLeftIcon } from './ChevronLeftIcon';
 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';
@@ -48,6 +49,7 @@ export { HomeFillIcon } from './HomeFillIcon';
 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';
@@ -60,6 +62,7 @@ export { NoDataIcon } from './NoDataIcon';
 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';
index 8c8304dcc1d4766ca74d72b75e68ca7145a70389..ddc35bf7215550d73b98e18fbd4b1a5256c19dc5 100644 (file)
@@ -281,7 +281,9 @@ export const lightTheme = {
     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,
diff --git a/server/sonar-web/src/main/js/api/fix-suggestions.ts b/server/sonar-web/src/main/js/api/fix-suggestions.ts
new file mode 100644 (file)
index 0000000..84570ff
--- /dev/null
@@ -0,0 +1,29 @@
+/*
+ * 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);
+}
diff --git a/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts
new file mode 100644 (file)
index 0000000..be6dfcf
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * 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);
+    });
+  }
+}
index f83fc7ab92000cd4f1e5652900b79e2dc3ff5fc6..09cb5e4f844c3e66251f20fc3cb636802aa714bb 100644 (file)
  */
 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,
@@ -30,6 +33,7 @@ import {
   issuesHandler,
   renderIssueApp,
   renderProjectIssuesApp,
+  sourcesHandler,
   ui,
   usersHandler,
 } from '../test-utils';
@@ -76,6 +80,57 @@ describe('issue app', () => {
     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');
 
index 7a7383db6b97b39444ebec65d0ca759f8479b6a2..2ce9343bb7665129ad173e794e730e3c85dca71d 100644 (file)
@@ -52,6 +52,7 @@ import withIndexationContext, {
   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';
@@ -1246,6 +1247,13 @@ export class App extends React.PureComponent<Props, State> {
                       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}
index 1ca660d65443f927ceb6a3551ea1da34d2ae8929..ba696076c9d6483dce5e3598a778845355894db8 100644 (file)
  * 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,
@@ -42,6 +53,7 @@ import {
   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';
@@ -56,6 +68,7 @@ import {
 
 interface Props {
   branchLike: BranchLike | undefined;
+  currentUser: CurrentUser;
   duplications?: Duplication[];
   duplicationsByLine?: { [line: number]: number[] };
   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
@@ -81,10 +94,7 @@ interface State {
   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>) {
@@ -219,7 +229,8 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
   };
 
   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])
@@ -243,6 +254,8 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
               <IssueSourceViewerScrollContext.Consumer key={issueToDisplay.key}>
                 {(ctx) => (
                   <LineFinding
+                    as={isSelectedIssue ? 'div' : undefined}
+                    className="sw-justify-between"
                     issueKey={issueToDisplay.key}
                     message={
                       <IssueMessageHighlighting
@@ -253,6 +266,11 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone
                     selected={isSelectedIssue}
                     ref={isSelectedIssue ? ctx?.registerPrimaryLocationRef : undefined}
                     onIssueSelect={this.props.onIssueSelect}
+                    getFixButton={
+                      isSelectedIssue ? (
+                        <GetFixButton issue={issueToDisplay} currentUser={currentUser} />
+                      ) : undefined
+                    }
                   />
                 )}
               </IssueSourceViewerScrollContext.Consumer>
@@ -394,3 +412,48 @@ function isExpandable(snippets: Snippet[], snippetGroup: SnippetGroup) {
 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);
index 528712bb1634d2277653e26e4e0375a47df11c98..8c04b2ce59099fa5197eb12420aa91d053862b38 100644 (file)
@@ -55,6 +55,8 @@ export interface Props {
   linkToProject?: boolean;
   loading?: boolean;
   onExpand?: () => void;
+  shouldShowOpenInIde?: boolean;
+  shouldShowViewAllIssues?: boolean;
   sourceViewerFile: SourceViewerFile;
 }
 
@@ -68,6 +70,8 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) {
     loading,
     onExpand,
     sourceViewerFile,
+    shouldShowOpenInIde = true,
+    shouldShowViewAllIssues = true,
   } = props;
 
   const { measures, path, project, projectName, q } = sourceViewerFile;
@@ -146,7 +150,7 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) {
         )}
       </div>
 
-      {!isProjectRoot && isLoggedIn(currentUser) && !isLoadingBranches && (
+      {!isProjectRoot && shouldShowOpenInIde && isLoggedIn(currentUser) && !isLoadingBranches && (
         <IssueOpenInIdeButton
           branchName={branchName}
           issueKey={issueKey}
@@ -156,7 +160,7 @@ export function IssueSourceViewerHeader(props: Readonly<Props>) {
         />
       )}
 
-      {!isProjectRoot && measures.issues !== undefined && (
+      {!isProjectRoot && shouldShowViewAllIssues && measures.issues !== undefined && (
         <div
           className={classNames('sw-ml-4', {
             'sw-mr-1': (!expandable || loading) ?? isLoadingBranches,
index 3963c7fb4c253b2f4c934210693110caec7fb4a1..995d63c8259a13347807713733c8383ae7a5d164 100644 (file)
@@ -23,6 +23,7 @@ import { Outlet, Route } from 'react-router-dom';
 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';
@@ -45,9 +46,13 @@ export const issuesHandler = new IssuesServiceMock(usersHandler);
 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' }),
@@ -90,6 +95,10 @@ export const ui = {
   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',
   }),
@@ -147,6 +156,8 @@ export const ui = {
   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`,
@@ -184,6 +195,7 @@ export function renderProjectIssuesApp(
       [NoticeType.ISSUE_NEW_STATUS_AND_TRANSITION_GUIDE]: true,
     },
   }),
+  featureList = [Feature.BranchSupport],
 ) {
   renderAppWithComponentContext(
     'project/issues',
@@ -198,7 +210,7 @@ export function renderProjectIssuesApp(
         {projectIssuesRoutes()}
       </Route>
     ),
-    { navigateTo, currentUser, featureList: [Feature.BranchSupport] },
+    { navigateTo, currentUser, featureList },
     { component: mockComponent(overrides) },
   );
 }
diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx
new file mode 100644 (file)
index 0000000..482ebc1
--- /dev/null
@@ -0,0 +1,91 @@
+/*
+ * 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} />
+      )}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx
new file mode 100644 (file)
index 0000000..731e854
--- /dev/null
@@ -0,0 +1,214 @@
+/*
+ * 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')};
+`;
diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx
new file mode 100644 (file)
index 0000000..d2cecf9
--- /dev/null
@@ -0,0 +1,145 @@
+/*
+ * 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')};
+`;
index c1ccaa506e127b5df022b7554c5c8aa34703e507..fa4fff30c061a7a7e59cfb3b345e0f7d05a7c438 100644 (file)
@@ -23,6 +23,7 @@ import { cloneDeep, debounce, groupBy } from 'lodash';
 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';
@@ -31,17 +32,21 @@ import IssueHeader from '../../apps/issues/components/IssueHeader';
 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;
@@ -49,6 +54,7 @@ interface IssueTabViewerProps extends CurrentUserContextInterface {
   ruleDetails: RuleDetails;
   selectedFlowIndex?: number;
   selectedLocationIndex?: number;
+  suggestionTabContent?: React.ReactNode;
 }
 interface State {
   displayEducationalPrinciplesNotification?: boolean;
@@ -70,6 +76,7 @@ export enum TabKeys {
   WhyIsThisAnIssue = 'why',
   HowToFixIt = 'how_to_fix',
   AssessTheIssue = 'assess_the_problem',
+  CodeFix = 'code_fix',
   Activity = 'activity',
   MoreInfo = 'more_info',
 }
@@ -127,7 +134,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
       prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey ||
       prevProps.issue !== issue ||
       prevProps.selectedFlowIndex !== selectedFlowIndex ||
-      prevProps.selectedLocationIndex !== selectedLocationIndex ||
+      (prevProps.selectedLocationIndex ?? -1) !== (selectedLocationIndex ?? -1) ||
       prevProps.currentUser !== currentUser
     ) {
       this.setState((pState) =>
@@ -172,9 +179,12 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
 
     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,
     };
   };
@@ -182,11 +192,14 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
   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
@@ -253,6 +266,16 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
           />
         ),
       },
+      ...(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,
@@ -330,9 +353,11 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
   };
 
   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() {
@@ -390,7 +415,9 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
                     })}
                     key={tab.key}
                   >
-                    {tab.content}
+                    <TabSelectorContext.Provider value={this.handleSelectTabs}>
+                      {tab.content}
+                    </TabSelectorContext.Provider>
                   </div>
                 ))
               }
@@ -402,4 +429,4 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta
   }
 }
 
-export default withCurrentUserContext(withLocation(IssueTabViewer));
+export default withCurrentUserContext(withLocation(withAvailableFeatures(IssueTabViewer)));
diff --git a/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts b/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts
new file mode 100644 (file)
index 0000000..5f1a4d6
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * 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);
index 642564b73d47e15830054edc2f602418d4668b25..e60bec4f6da50ff9deb6217d87320ea0f386a64f 100644 (file)
@@ -21,7 +21,14 @@ import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';
 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';
 
@@ -94,3 +101,12 @@ export const useComponentDataQuery = createQueryHook(
     });
   },
 );
+
+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,
+  });
+}
diff --git a/server/sonar-web/src/main/js/queries/fix-suggestions.ts b/server/sonar-web/src/main/js/queries/fix-suggestions.ts
new file mode 100644 (file)
index 0000000..c40d76b
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * 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,
+      };
+    },
+  });
+}
index 320e0f96300189527467c2f586d2685ef76fd564..abcfe8fbb64d4275db1150536671b6205aaacca8 100644 (file)
@@ -29,4 +29,5 @@ export enum Feature {
   GithubProvisioning = 'github-provisioning',
   GitlabProvisioning = 'gitlab-provisioning',
   PrioritizedRules = 'prioritized-rules',
+  FixSuggestions = 'fix-suggestions',
 }
diff --git a/server/sonar-web/src/main/js/types/fix-suggestions.ts b/server/sonar-web/src/main/js/types/fix-suggestions.ts
new file mode 100644 (file)
index 0000000..124684f
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * 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;
+}
index b9b11af3068adab681e97665fccc4f9bb2166ed6..2701872489e26258662ba6761a348505301c93c9 100644 (file)
@@ -1163,6 +1163,17 @@ issue.flow.x_steps={0} steps
 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
@@ -2614,6 +2625,7 @@ coding_rules.description_section.title.root_cause=Why is this an issue?
 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