aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>2024-08-28 16:40:16 +0200
committersonartech <sonartech@sonarsource.com>2024-09-04 20:03:11 +0000
commit0996c6186cfeb8e2c763d2f174b26ab276b232f7 (patch)
tree6a56285befb26ce1b41d9065ab86379cb91d31ac /server/sonar-web/src/main
parentbed8af05f0c3f2b4c6018634d3d7aaba5b45a83c (diff)
downloadsonarqube-0996c6186cfeb8e2c763d2f174b26ab276b232f7.tar.gz
sonarqube-0996c6186cfeb8e2c763d2f174b26ab276b232f7.zip
CODEFIX-12 Show new suggestion feature in issues page
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/api/fix-suggestions.ts29
-rw-r--r--server/sonar-web/src/main/js/api/mocks/FixIssueServiceMock.ts59
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssueApp-it.tsx57
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx73
-rw-r--r--server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/issues/test-utils.tsx14
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx91
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx214
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx145
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx43
-rw-r--r--server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts24
-rw-r--r--server/sonar-web/src/main/js/queries/component.ts18
-rw-r--r--server/sonar-web/src/main/js/queries/fix-suggestions.ts134
-rw-r--r--server/sonar-web/src/main/js/types/features.ts1
-rw-r--r--server/sonar-web/src/main/js/types/fix-suggestions.ts31
16 files changed, 931 insertions, 18 deletions
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<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
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<T>(response: T): Promise<T> {
+ 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<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}
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<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);
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<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,
diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx
index 3963c7fb4c2..995d63c8259 100644
--- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx
@@ -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
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<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
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<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
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 (
+ <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')};
+`;
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<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
index 00000000000..5f1a4d6e90e
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts
@@ -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);
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<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,
+ };
+ },
+ });
+}
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;
+}