From 0996c6186cfeb8e2c763d2f174b26ab276b232f7 Mon Sep 17 00:00:00 2001 From: Revanshu Paliwal Date: Wed, 28 Aug 2024 16:40:16 +0200 Subject: [PATCH] CODEFIX-12 Show new suggestion feature in issues page --- .../src/components/CodeSyntaxHighlighter.tsx | 6 +- .../components/__tests__/LineFinding-test.tsx | 2 +- .../components/__tests__/LineWrapper-test.tsx | 11 +- .../__snapshots__/LineCoverage-test.tsx.snap | 10 +- .../__snapshots__/LineFinding-test.tsx.snap | 20 +- .../src/components/code-line/LineFinding.tsx | 27 ++- .../src/components/code-line/LineStyles.tsx | 19 ++ .../src/components/code-line/LineWrapper.tsx | 14 ++ .../src/components/icons/CodeEllipsisIcon.tsx | 42 ++++ .../src/components/icons/InProgressVisual.tsx | 106 +++++++++ .../icons/OverviewQGNotComputedIcon.tsx | 135 +++++++++++ .../src/components/icons/index.ts | 3 + .../design-system/src/theme/light.ts | 2 + .../src/main/js/api/fix-suggestions.ts | 29 +++ .../main/js/api/mocks/FixIssueServiceMock.ts | 59 +++++ .../js/apps/issues/__tests__/IssueApp-it.tsx | 57 ++++- .../js/apps/issues/components/IssuesApp.tsx | 8 + .../ComponentSourceSnippetGroupViewer.tsx | 73 +++++- .../IssueSourceViewerHeader.tsx | 8 +- .../src/main/js/apps/issues/test-utils.tsx | 14 +- .../rules/IssueSuggestionCodeTab.tsx | 91 ++++++++ .../rules/IssueSuggestionFileSnippet.tsx | 214 ++++++++++++++++++ .../components/rules/IssueSuggestionLine.tsx | 145 ++++++++++++ .../js/components/rules/IssueTabViewer.tsx | 43 +++- .../js/components/rules/TabSelectorContext.ts | 24 ++ .../src/main/js/queries/component.ts | 18 +- .../src/main/js/queries/fix-suggestions.ts | 134 +++++++++++ .../sonar-web/src/main/js/types/features.ts | 1 + .../src/main/js/types/fix-suggestions.ts | 31 +++ .../resources/org/sonar/l10n/core.properties | 12 + 30 files changed, 1325 insertions(+), 33 deletions(-) create mode 100644 server/sonar-web/design-system/src/components/icons/CodeEllipsisIcon.tsx create mode 100644 server/sonar-web/design-system/src/components/icons/InProgressVisual.tsx create mode 100644 server/sonar-web/design-system/src/components/icons/OverviewQGNotComputedIcon.tsx create mode 100644 server/sonar-web/src/main/js/api/fix-suggestions.ts create mode 100644 server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts create mode 100644 server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx create mode 100644 server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx create mode 100644 server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx create mode 100644 server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts create mode 100644 server/sonar-web/src/main/js/queries/fix-suggestions.ts create mode 100644 server/sonar-web/src/main/js/types/fix-suggestions.ts diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx index 100bcbe6ee8..57cd229e71f 100644 --- a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx +++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx @@ -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 { diff --git a/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx index 20095ec4b94..038ae20ae92 100644 --- a/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx @@ -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(); }); diff --git a/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx index 03bb657177e..32f97a32f8e 100644 --- a/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx @@ -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(, { + container: document.createElement('div'), + }); + expect(container.container.firstChild).toHaveStyle({ + '--columns': '44px 26px 1rem 1fr', + }); +}); + function setupWithProps(props: Partial> = {}) { return render( , diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap index 5423d1645ff..0c1e1918e4f 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap @@ -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); } diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap index d4ef5c08bf2..4b2719e5a41 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap @@ -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); +} +
- + +
`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx b/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx index 5169279dacf..3d30a04bb25 100644 --- a/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx +++ b/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx @@ -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, ) { - return ( + return selected ? ( + + {onIssueSelect ? ( + { + onIssueSelect(issueKey); + }} + > + {message} + + ) : ( + message + )} + {getFixButton} + + ) : ( ` ${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 diff --git a/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx b/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx index 82d2ccea7d8..c576954ab47 100644 --- a/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx +++ b/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx @@ -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')}; + } +`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx b/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx index 34b78b601fa..1d666acd77a 100644 --- a/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx +++ b/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx @@ -47,3 +47,17 @@ export function LineWrapper(props: Props) { /> ); } + +export function SuggestedLineWrapper(props: Readonly>) { + const theme = useTheme(); + return ( + + ); +} 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 index 00000000000..36a3df4b3fd --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/CodeEllipsisIcon.tsx @@ -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) { + if (direction === CodeEllipsisDirection.Up) { + return ; + } else if (direction === CodeEllipsisDirection.Down) { + return ; + } + return ; +} 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 index 00000000000..f3853570fcb --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/InProgressVisual.tsx @@ -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 ( + + + + + + + + + + + + + + + + ); +} + +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 index 00000000000..aa7ba5e6b8c --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/OverviewQGNotComputedIcon.tsx @@ -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) { + const theme = useTheme(); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts index deaf64a2f59..1cccc42ba0c 100644 --- a/server/sonar-web/design-system/src/components/icons/index.ts +++ b/server/sonar-web/design-system/src/components/icons/index.ts @@ -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'; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 8c8304dcc1d..ddc35bf7215 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -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 index 00000000000..84570ff31e1 --- /dev/null +++ b/server/sonar-web/src/main/js/api/fix-suggestions.ts @@ -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 { + return axiosToCatch.post('/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 index 00000000000..be6dfcfcf69 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts @@ -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(response: T): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(cloneDeep(response)); + }, 10); + }); + } +} diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx index f83fc7ab920..09cb5e4f844 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx @@ -19,10 +19,13 @@ */ 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'); diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 7a7383db6b9..2ce9343bb76 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -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 { selectedLocationIndex={this.state.selectedLocationIndex} /> } + suggestionTabContent={ + + } extendedDescription={openRuleDetails.htmlNote} issue={openIssue} onIssueChange={this.handleIssueChange} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx index 1ca660d6544..ba696076c9d 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx @@ -18,23 +18,34 @@ * 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, - State -> { +class ComponentSourceSnippetGroupViewer extends React.PureComponent, State> { mounted = false; constructor(props: Readonly) { @@ -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 {(ctx) => ( + ) : undefined + } /> )} @@ -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 ( + + {suggestion !== undefined && ( + + )} + {suggestion === undefined && ( + + )} + + ); +} + +export default withCurrentUserContext(ComponentSourceSnippetGroupViewer); diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx index 528712bb163..8c04b2ce590 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx @@ -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) { loading, onExpand, sourceViewerFile, + shouldShowOpenInIde = true, + shouldShowViewAllIssues = true, } = props; const { measures, path, project, projectName, q } = sourceViewerFile; @@ -146,7 +150,7 @@ export function IssueSourceViewerHeader(props: Readonly) { )} - {!isProjectRoot && isLoggedIn(currentUser) && !isLoadingBranches && ( + {!isProjectRoot && shouldShowOpenInIde && isLoggedIn(currentUser) && !isLoadingBranches && ( ) { /> )} - {!isProjectRoot && measures.issues !== undefined && ( + {!isProjectRoot && shouldShowViewAllIssues && measures.issues !== undefined && (
), - { 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 index 00000000000..482ebc1ab19 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx @@ -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) { + 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 && ( +
+ +

+ {translate('issues.code_fix.let_us_suggest_fix')} +

+ +
+ )} + {isLoading && ( +
+ +

+ {translate('issues.code_fix.fix_is_being_generated')} +

+
+ )} + {isError && ( +
+ +

+ {translate('issues.code_fix.something_went_wrong')} +

+

{translate('issues.code_fix.not_able_to_generate_fix')}

+ {translate('issues.code_fix.check_how_to_fix')} + {!isIssueRawError && ( + + )} +
+ )} + + {!isPending && !isError && ( + + )} + + ); +} 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 index 00000000000..731e854c239 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx @@ -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) { + const [displayedLine, setDisplayedLine] = useState([]); + + 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 ( +
+ {sourceViewerFile && ( + + )} + + + {displayedLine[0]?.lineBefore !== 1 && ( + + handleExpand( + 0, + max([displayedLine[0].lineBefore - EXPAND_SIZE, 0]), + displayedLine[0].lineBefore, + ) + } + style={{ borderTop: 'none' }} + > + + + )} + {displayedLine.map((line, index) => ( + ${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 ? ( + <> + + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + displayedLine[index - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + > + + + + handleExpand(index, line.lineBefore - EXPAND_SIZE, line.lineBefore) + } + style={{ borderTop: 'none' }} + > + + + + ) : ( + + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + line.lineBefore, + ) + } + > + + + )} + + )} +
+ {line.copy !== undefined && ( + + )} + +
+
+ ))} + + {displayedLine[displayedLine.length - 1]?.lineBefore !== + suggestion.unifiedLines[suggestion.unifiedLines.length - 1]?.lineBefore && ( + + handleExpand( + displayedLine.length, + displayedLine[displayedLine.length - 1].lineBefore + 1, + displayedLine[displayedLine.length - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + style={{ borderBottom: 'none' }} + > + + + )} +
+
+

{suggestion.explanation}

+
+ ); +} + +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 index 00000000000..d2cecf9b809 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx @@ -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 ( + + + {type !== LineTypeEnum.ADDED && ( + {lineBefore} + )} + + + {type !== LineTypeEnum.REMOVED && ( + {lineAfter} + )} + + + {type === LineTypeEnum.REMOVED && ( + - + )} + {type === LineTypeEnum.ADDED && +} + + + {type === LineTypeEnum.CODE && ( + + ${line}`} + language={language} + escapeDom={false} + /> + + )} + {type === LineTypeEnum.REMOVED && ( + + + ${line}`} + language={language} + escapeDom={false} + /> + + + )} + {type === LineTypeEnum.ADDED && ( + + + ${line}`} + language={language} + escapeDom={false} + /> + + + )} + + + ); +} + +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')}; +`; diff --git a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx index c1ccaa506e1..fa4fff30c06 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx @@ -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 @@ -172,9 +179,12 @@ export class IssueTabViewer extends React.PureComponent { 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 ), }, + ...(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 { - 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 - {tab.content} + + {tab.content} +
)) } @@ -402,4 +429,4 @@ export class IssueTabViewer extends React.PureComponent void>(noop); diff --git a/server/sonar-web/src/main/js/queries/component.ts b/server/sonar-web/src/main/js/queries/component.ts index 642564b73d4..e60bec4f6da 100644 --- a/server/sonar-web/src/main/js/queries/component.ts +++ b/server/sonar-web/src/main/js/queries/component.ts @@ -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 index 00000000000..c40d76bf8ec --- /dev/null +++ b/server/sonar-web/src/main/js/queries/fix-suggestions.ts @@ -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((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, + }; + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/features.ts b/server/sonar-web/src/main/js/types/features.ts index 320e0f96300..abcfe8fbb64 100644 --- a/server/sonar-web/src/main/js/types/features.ts +++ b/server/sonar-web/src/main/js/types/features.ts @@ -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 index 00000000000..124684ff256 --- /dev/null +++ b/server/sonar-web/src/main/js/types/fix-suggestions.ts @@ -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; +} diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index b9b11af3068..2701872489e 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 -- 2.39.5