diff options
Diffstat (limited to 'app')
48 files changed, 946 insertions, 18 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index e2740981a4b..7607c4b3b79 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -25,6 +25,7 @@ const Api = { userStatusPath: '/api/:version/users/:id/status', userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', + applySuggestionPath: '/api/:version/suggestions/:id/apply', commitPipelinesPath: '/:project_id/commit/:sha/pipelines', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', createBranchPath: '/api/:version/projects/:id/repository/branches', @@ -185,6 +186,12 @@ const Api = { }); }, + applySuggestion(id) { + const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id)); + + return axios.put(url); + }, + commitPipelines(projectId, sha) { const encodedProjectId = projectId .split('/') diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index f0e82b1ed27..d4c1b07093d 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -42,6 +42,11 @@ export default { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, changesEmptyStateIllustration: { type: String, required: false, @@ -208,6 +213,7 @@ export default { v-for="file in diffFiles" :key="file.newPath" :file="file" + :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" /> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 11cc4c09fed..ac963f2971e 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -23,6 +23,11 @@ export default { type: Object, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState({ @@ -74,11 +79,13 @@ export default { v-if="isInlineView" :diff-file="diffFile" :diff-lines="diffFile.highlighted_diff_lines || []" + :help-page-path="helpPagePath" /> <parallel-diff-view v-if="isParallelView" :diff-file="diffFile" :diff-lines="diffFile.parallel_diff_lines || []" + :help-page-path="helpPagePath" /> </template> <diff-viewer diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index bee29b04e92..b2021cd6061 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -13,6 +13,11 @@ export default { type: Array, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, shouldCollapseDiscussions: { type: Boolean, required: false, @@ -23,6 +28,11 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, methods: { ...mapActions(['toggleDiscussion']), @@ -72,6 +82,8 @@ export default { :render-diff-file="false" :always-expanded="true" :discussions-by-diff-order="true" + :line="line" + :help-page-path="helpPagePath" @noteDeleted="deleteNoteHandler" > <span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill"> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index bed29efb253..449f7007077 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -23,6 +23,11 @@ export default { type: Boolean, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -164,6 +169,7 @@ export default { v-if="!isCollapsed && file.renderIt" :class="{ hidden: isCollapsed || file.too_large }" :diff-file="file" + :help-page-path="helpPagePath" /> <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed"> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 9fd02acbd6e..e7569ba7b84 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -94,6 +94,7 @@ export default { ref="noteForm" :is-editing="true" :line-code="line.line_code" + :line="line" save-button-title="Comment" class="diff-comment-form" @cancelForm="handleCancelCommentForm" diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue index aa40b24950a..814ee0b7c02 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { className() { @@ -38,7 +43,12 @@ export default { <tr v-if="shouldRender" :class="className" class="notes_holder"> <td class="notes_content" colspan="3"> <div class="content"> - <diff-discussions v-if="line.discussions.length" :discussions="line.discussions" /> + <diff-discussions + v-if="line.discussions.length" + :line="line" + :discussions="line.discussions" + :help-page-path="helpPagePath" + /> <diff-line-note-form v-if="line.hasForm" :diff-file-hash="diffFileHash" diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 6a0ce760e6d..9310e2b7ca9 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -17,6 +17,11 @@ export default { type: Array, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('diffs', ['commitId']), @@ -47,6 +52,7 @@ export default { :key="`icr-${index}`" :diff-file-hash="diffFile.file_hash" :line="line" + :help-page-path="helpPagePath" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue index b98463d3dd3..a65cf025cde 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue @@ -20,6 +20,11 @@ export default { type: Number, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { hasExpandedDiscussionOnLeft() { @@ -87,6 +92,8 @@ export default { <diff-discussions v-if="line.left.discussions.length" :discussions="line.left.discussions" + :line="line.left" + :help-page-path="helpPagePath" /> </div> <diff-line-note-form @@ -102,6 +109,8 @@ export default { <diff-discussions v-if="line.right.discussions.length" :discussions="line.right.discussions" + :line="line.right" + :help-page-path="helpPagePath" /> </div> <diff-line-note-form diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 9a6e0e82529..e6bc0daebb3 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -17,6 +17,11 @@ export default { type: Array, required: true, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapGetters('diffs', ['commitId']), @@ -49,6 +54,7 @@ export default { :line="line" :diff-file-hash="diffFile.file_hash" :line-index="index" + :help-page-path="helpPagePath" /> </template> </tbody> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 915cacb374f..b130cedc24c 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -16,6 +16,7 @@ export default function initDiffsApp(store) { return { endpoint: dataset.endpoint, projectPath: dataset.projectPath, + helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, }; @@ -31,6 +32,7 @@ export default function initDiffsApp(store) { endpoint: this.endpoint, currentUser: this.currentUser, projectPath: this.projectPath, + helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', changesEmptyStateIllustration: this.changesEmptyStateIllustration, }, diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 3618c6af7e2..c095a017866 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) { } } -function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) { +function moveCursor({ + textArea, + tag, + cursorOffset, + positionBetweenTags, + removedLastNewLine, + select, +}) { var pos; if (!textArea.setSelectionRange) { return; @@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se pos -= 1; } + if (cursorOffset) { + pos -= cursorOffset; + } + return textArea.setSelectionRange(pos, pos); } } -export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) { +export function insertMarkdownText({ + textArea, + text, + tag, + cursorOffset, + blockTag, + selected, + wrap, + select, +}) { var textToInsert, selectedSplit, startChar, @@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr return moveCursor({ textArea, tag: tag.replace(textPlaceholder, selected), + cursorOffset, positionBetweenTags: wrap && selected.length === 0, removedLastNewLine, select, }); } -function updateText({ textArea, tag, blockTag, wrap, select }) { +function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) { var $textArea, selected, text; $textArea = $(textArea); textArea = $textArea.get(0); text = $textArea.val(); - selected = selectedText(text, textArea); + selected = selectedText(text, textArea) || tagContent; $textArea.focus(); - return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }); + return insertMarkdownText({ + textArea, + text, + tag, + cursorOffset, + blockTag, + selected, + wrap, + select, + }); } export function addMarkdownListeners(form) { @@ -178,9 +208,11 @@ export function addMarkdownListeners(form) { return updateText({ textArea: $this.closest('.md-area').find('textarea'), tag: $this.data('mdTag'), + cursorOffset: $this.data('mdCursorOffset'), blockTag: $this.data('mdBlock'), wrap: !$this.data('mdPrepend'), select: $this.data('mdSelect'), + tagContent: $this.data('mdTagContent').toString(), }); }); } diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index 1c98683c597..e4d72eb8318 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -33,6 +33,7 @@ export default function initMrNotes() { noteableData, currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), + helpPagePath: notesDataset.helpPagePath, }; }, computed: { @@ -71,6 +72,7 @@ export default function initMrNotes() { notesData: this.notesData, userData: this.currentUserData, shouldShow: this.activeTab === 'show', + helpPagePath: this.helpPagePath, }, }); }, diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index c0bee600181..bcf5d334da4 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,10 +1,12 @@ <script> +import { mapActions } from 'vuex'; import $ from 'jquery'; import noteEditedText from './note_edited_text.vue'; import noteAwardsList from './note_awards_list.vue'; import noteAttachment from './note_attachment.vue'; import noteForm from './note_form.vue'; import autosave from '../mixins/autosave'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { @@ -12,6 +14,7 @@ export default { noteAwardsList, noteAttachment, noteForm, + Suggestions, }, mixins: [autosave], props: { @@ -19,6 +22,11 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, canEdit: { type: Boolean, required: true, @@ -28,11 +36,22 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, computed: { noteBody() { return this.note.note; }, + hasSuggestion() { + return this.note.suggestions && this.note.suggestions.length; + }, + lineType() { + return this.line ? this.line.type : null; + }, }, mounted() { this.renderGFM(); @@ -53,6 +72,7 @@ export default { } }, methods: { + ...mapActions(['submitSuggestion']), renderGFM() { $(this.$refs['note-body']).renderGFM(); }, @@ -62,19 +82,35 @@ export default { formCancelHandler(shouldConfirm, isDirty) { this.$emit('cancelForm', shouldConfirm, isDirty); }, + applySuggestion({ suggestionId, flashContainer, callback }) { + const { discussion_id: discussionId, id: noteId } = this.note; + + this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback }); + }, }, }; </script> <template> <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body"> - <div class="note-text md" v-html="note.note_html"></div> + <suggestions + v-if="hasSuggestion && !isEditing" + :suggestions="note.suggestions" + :note-html="note.note_html" + :line-type="lineType" + :help-page-path="helpPagePath" + @apply="applySuggestion" + /> + <div v-else class="note-text md" v-html="note.note_html"></div> <note-form v-if="isEditing" ref="noteForm" :is-editing="isEditing" :note-body="noteBody" :note-id="note.id" + :line="line" + :note="note" + :help-page-path="helpPagePath" :markdown-version="note.cached_markdown_version" @handleFormUpdate="handleFormUpdate" @cancelForm="formCancelHandler" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 95164183ccb..9b7f3d3588d 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,4 +1,5 @@ <script> +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mapGetters, mapActions } from 'vuex'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -53,6 +54,21 @@ export default { required: false, default: false, }, + line: { + type: Object, + required: false, + default: null, + }, + note: { + type: Object, + required: false, + default: null, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -79,7 +95,8 @@ export default { return '#'; }, markdownPreviewPath() { - return this.getNoteableDataByProp('preview_note_path'); + const notable = this.getNoteableDataByProp('preview_note_path'); + return mergeUrlParams({ preview_suggestions: true }, notable); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -93,6 +110,18 @@ export default { isDisabled() { return !this.updatedNoteBody.length || this.isSubmitting; }, + discussionNote() { + const discussionNote = this.discussion.id + ? this.getDiscussionLastNote(this.discussion) + : this.note; + return discussionNote || {}; + }, + canSuggest() { + return ( + this.getNoteableData.can_receive_suggestion && + (this.line && this.line.can_receive_suggestion) + ); + }, }, watch: { noteBody() { @@ -171,7 +200,11 @@ export default { :markdown-docs-path="markdownDocsPath" :markdown-version="markdownVersion" :quick-actions-docs-path="quickActionsDocsPath" + :line="line" + :note="discussionNote" + :can-suggest="canSuggest" :add-spacing-classes="false" + :help-page-path="helpPagePath" > <textarea id="note_note" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 5c9a28b8512..4156fe0d229 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -49,6 +49,11 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, renderDiffFile: { type: Boolean, required: false, @@ -64,6 +69,11 @@ export default { required: false, default: false, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { const { diff_discussion: isDiffDiscussion, resolved } = this.discussion; @@ -194,6 +204,13 @@ export default { false, ); }, + diffLine() { + if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) { + return this.discussion.truncated_diff_lines.slice(-1)[0]; + } + + return this.line; + }, }, watch: { isReplying() { @@ -357,6 +374,8 @@ Please check your network connection and try again.`; <component :is="componentName(initialDiscussion)" :note="componentData(initialDiscussion)" + :line="line" + :help-page-path="helpPagePath" @handleDeleteNote="deleteNoteHandler" > <slot slot="avatar-badge" name="avatar-badge"></slot> @@ -373,6 +392,8 @@ Please check your network connection and try again.`; v-for="note in replies" :key="note.id" :note="componentData(note)" + :help-page-path="helpPagePath" + :line="line" @handleDeleteNote="deleteNoteHandler" /> </template> @@ -383,6 +404,8 @@ Please check your network connection and try again.`; v-for="(note, index) in discussion.notes" :key="note.id" :note="componentData(note)" + :help-page-path="helpPagePath" + :line="diffLine" @handleDeleteNote="deleteNoteHandler" > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> @@ -447,6 +470,7 @@ Please check your network connection and try again.`; ref="noteForm" :discussion="discussion" :is-editing="false" + :line="diffLine" save-button-title="Comment" @handleFormUpdate="saveReply" @cancelForm="cancelReplyForm" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index a17be51353e..57e9c40bd61 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -27,6 +27,16 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -220,8 +230,10 @@ export default { <note-body ref="noteBody" :note="note" + :line="line" :can-edit="note.current_user.can_edit" :is-editing="isEditing" + :help-page-path="helpPagePath" @handleFormUpdate="formUpdateHandler" @cancelForm="formCancelHandler" /> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 27f896cee35..f3fcfdfda05 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -49,6 +49,11 @@ export default { required: false, default: 0, }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -206,6 +211,7 @@ export default { :key="discussion.id" :discussion="discussion" :render-diff-file="true" + :help-page-path="helpPagePath" /> </template> </ul> diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index 47a6f07cce2..237e70c0a4c 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Api from '~/api'; import VueResource from 'vue-resource'; import * as constants from '../constants'; @@ -44,4 +45,7 @@ export default { toggleIssueState(endpoint, data) { return Vue.http.put(endpoint, data); }, + applySuggestion(id) { + return Api.applySuggestion(id); + }, }; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 4716ab52333..65f85314fa0 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -405,5 +405,25 @@ export const startTaskList = ({ dispatch }) => export const updateResolvableDiscussonsCounts = ({ commit }) => commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS); +export const submitSuggestion = ( + { commit }, + { discussionId, noteId, suggestionId, flashContainer, callback }, +) => { + service + .applySuggestion(suggestionId) + .then(() => { + commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }); + callback(); + }) + .catch(() => { + Flash( + __('Something went wrong while applying the suggestion. Please try again.'), + 'alert', + flashContainer, + ); + callback(); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index b5fe8bdb1d3..887e6d22b06 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -20,6 +20,7 @@ export default () => ({ userData: {}, noteableData: { current_user: {}, + preview_note_path: 'path/to/preview', }, commentsDisabled: false, resolvableDiscussionsCount: 0, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 9c68ab67a8c..df943c155f4 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -16,6 +16,7 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; export const DISABLE_COMMENTS = 'DISABLE_COMMENTS'; +export const APPLY_SUGGESTION = 'APPLY_SUGGESTION'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 39ff0ff73d7..8992454be2e 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -197,6 +197,17 @@ export default { } }, + [types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) { + const noteObj = utils.findNoteObjectById(state.discussions, discussionId); + const comment = utils.findNoteObjectById(noteObj.notes, noteId); + + comment.suggestions = comment.suggestions.map(suggestion => ({ + ...suggestion, + applied: suggestion.applied || suggestion.id === suggestionId, + appliable: false, + })); + }, + [types.UPDATE_DISCUSSION](state, noteData) { const note = noteData; const selectedDiscussion = state.discussions.find(disc => disc.id === note.id); diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 43def2673eb..2f7ed4a982c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,17 +1,21 @@ <script> import $ from 'jquery'; +import _ from 'underscore'; import { __ } from '~/locale'; +import { stripHtml } from '~/lib/utils/text_utility'; import Flash from '../../../flash'; import GLForm from '../../../gl_form'; import markdownHeader from './header.vue'; import markdownToolbar from './toolbar.vue'; import icon from '../icon.vue'; +import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; export default { components: { markdownHeader, markdownToolbar, icon, + Suggestions, }, props: { markdownPreviewPath: { @@ -48,12 +52,33 @@ export default { required: false, default: true, }, + line: { + type: Object, + required: false, + default: null, + }, + note: { + type: Object, + required: false, + default: () => ({}), + }, + canSuggest: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, }, data() { return { markdownPreview: '', referencedCommands: '', referencedUsers: '', + hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, }; @@ -63,6 +88,39 @@ export default { const referencedUsersThreshold = 10; return this.referencedUsers.length >= referencedUsersThreshold; }, + lineContent() { + const FIRST_CHAR_REGEX = /^(\+|-)/; + const [firstSuggestion] = this.suggestions; + if (firstSuggestion) { + return firstSuggestion.from_content; + } + + if (this.line) { + const { rich_text: richText, text } = this.line; + + if (text) { + return text.replace(FIRST_CHAR_REGEX, ''); + } + + return _.unescape(stripHtml(richText).replace(/\n/g, '')); + } + + return ''; + }, + lineNumber() { + let lineNumber; + if (this.line) { + const { new_line: newLine, old_line: oldLine } = this.line; + lineNumber = newLine || oldLine; + } + return lineNumber; + }, + suggestions() { + return this.note.suggestions || []; + }, + lineType() { + return this.line ? this.line.type : ''; + }, }, mounted() { /* @@ -122,6 +180,7 @@ export default { if (data.references) { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; + this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; } this.$nextTick(() => { @@ -147,6 +206,8 @@ export default { > <markdown-header :preview-markdown="previewMarkdown" + :line-content="lineContent" + :can-suggest="canSuggest" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" /> @@ -163,19 +224,39 @@ export default { /> </div> </div> - <div - v-show="previewMarkdown" - ref="markdown-preview" - class="md-preview js-vue-md-preview md md-preview-holder" - v-html="markdownPreview" - ></div> + <template v-if="hasSuggestion"> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="md-preview js-vue-md-preview md md-preview-holder" + > + <suggestions + v-if="hasSuggestion" + :note-html="markdownPreview" + :from-line="lineNumber" + :from-content="lineContent" + :line-type="lineType" + :disabled="true" + :suggestions="suggestions" + :help-page-path="helpPagePath" + /> + </div> + </template> + <template v-else> + <div + v-show="previewMarkdown" + ref="markdown-preview" + class="md-preview js-vue-md-preview md md-preview-holder" + v-html="markdownPreview" + ></div> + </template> <template v-if="previewMarkdown && !markdownPreviewLoading"> <div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> <span> - <i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add + <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add <strong> - <span class="js-referenced-users-count"> {{ referencedUsers.length }} </span> + <span class="js-referenced-users-count">{{ referencedUsers.length }}</span> </strong> people to the discussion. Proceed with caution. </span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4c4ba537065..bf4d42670ee 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -17,6 +17,16 @@ export default { type: Boolean, required: true, }, + lineContent: { + type: String, + required: false, + default: '', + }, + canSuggest: { + type: Boolean, + required: false, + default: true, + }, }, computed: { mdTable() { @@ -27,6 +37,9 @@ export default { '| cell | cell |', ].join('\n'); }, + mdSuggestion() { + return ['```suggestion', `{text}`, '```'].join('\n'); + }, }, mounted() { $(document).on('markdown-preview:show.vue', this.previewMarkdownTab); @@ -119,6 +132,16 @@ export default { :button-title="__('Add a table')" icon="table" /> + <toolbar-button + v-if="canSuggest" + :tag="mdSuggestion" + :prepend="true" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + class="qa-suggestion-btn" + /> <button v-gl-tooltip aria-label="Go full screen" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue new file mode 100644 index 00000000000..f98560f7336 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -0,0 +1,74 @@ +<script> +import SuggestionDiffHeader from './suggestion_diff_header.vue'; + +export default { + components: { + SuggestionDiffHeader, + }, + props: { + newLines: { + type: Array, + required: true, + }, + fromContent: { + type: String, + required: false, + default: '', + }, + fromLine: { + type: Number, + required: true, + }, + suggestion: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + methods: { + applySuggestion(callback) { + this.$emit('apply', { suggestionId: this.suggestion.id, callback }); + }, + }, +}; +</script> + +<template> + <div> + <suggestion-diff-header + class="qa-suggestion-diff-header" + :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" + :is-applied="suggestion.applied" + :help-page-path="helpPagePath" + @apply="applySuggestion" + /> + <table class="mb-3 md-suggestion-diff"> + <tbody> + <!-- Old Line --> + <tr class="line_holder old"> + <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td> + <td class="diff-line-num new_line old"></td> + <td class="line_content old"> + <span>{{ fromContent }}</span> + </td> + </tr> + <!-- New Line(s) --> + <tr v-for="(line, key) of newLines" :key="key" class="line_holder new"> + <td class="diff-line-num old_line new"></td> + <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td> + <td class="line_content new"> + <span>{{ line.content }}</span> + </td> + </tr> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue new file mode 100644 index 00000000000..563e2f94fcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -0,0 +1,60 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { Icon }, + props: { + canApply: { + type: Boolean, + required: false, + default: false, + }, + isApplied: { + type: Boolean, + required: true, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isAppliedSuccessfully: false, + isApplying: false, + }; + }, + methods: { + applySuggestion() { + if (!this.canApply) return; + this.isApplying = true; + this.$emit('apply', this.applySuggestionCallback); + }, + applySuggestionCallback() { + this.isApplying = false; + }, + }, +}; +</script> + +<template> + <div class="md-suggestion-header border-bottom-0 mt-2"> + <div class="qa-suggestion-diff-header font-weight-bold"> + {{ __('Suggested change') }} + <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')"> + <icon name="question-o" css-classes="link-highlight" /> + </a> + </div> + <span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span> + <button + v-if="canApply" + type="button" + class="btn qa-apply-btn" + :disabled="isApplying" + @click="applySuggestion" + > + {{ __('Apply suggestion') }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue new file mode 100644 index 00000000000..7c6dbee3e19 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -0,0 +1,136 @@ +<script> +import Vue from 'vue'; +import SuggestionDiff from './suggestion_diff.vue'; +import Flash from '~/flash'; + +export default { + components: { SuggestionDiff }, + props: { + fromLine: { + type: Number, + required: false, + default: 0, + }, + fromContent: { + type: String, + required: false, + default: '', + }, + lineType: { + type: String, + required: false, + default: '', + }, + suggestions: { + type: Array, + required: false, + default: () => [], + }, + noteHtml: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + helpPagePath: { + type: String, + required: true, + }, + }, + data() { + return { + isRendered: false, + }; + }, + watch: { + suggestions() { + this.reset(); + }, + noteHtml() { + this.reset(); + }, + }, + mounted() { + this.renderSuggestions(); + }, + methods: { + renderSuggestions() { + // swaps out suggestion(s) markdown with rich diff components + // (while still keeping non-suggestion markdown in place) + + if (!this.noteHtml) return; + const { container } = this.$refs; + const suggestionElements = container.querySelectorAll('.js-render-suggestion'); + + if (this.lineType === 'old') { + Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el); + } + + suggestionElements.forEach((suggestionEl, i) => { + const suggestionParentEl = suggestionEl.parentElement; + const newLines = this.extractNewLines(suggestionParentEl); + const diffComponent = this.generateDiff(newLines, i); + diffComponent.$mount(suggestionParentEl); + }); + + this.isRendered = true; + }, + extractNewLines(suggestionEl) { + // extracts the suggested lines from the markdown + // calculates a line number for each line + + const FIRST_CHAR_REGEX = /^(\+|-)/; + const newLines = suggestionEl.querySelectorAll('.line'); + const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; + const lines = []; + + newLines.forEach((line, i) => { + const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`; + const lineNumber = fromLine + i; + lines.push({ content, lineNumber }); + }); + + return lines; + }, + generateDiff(newLines, suggestionIndex) { + // generates the diff <suggestion-diff /> component + // all `suggestion` markdown will be swapped out by this component + + const { suggestions, disabled, helpPagePath } = this; + const suggestion = + suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; + const fromContent = suggestion.from_content || this.fromContent; + const fromLine = suggestion.from_line || this.fromLine; + const SuggestionDiffComponent = Vue.extend(SuggestionDiff); + const suggestionDiff = new SuggestionDiffComponent({ + propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath }, + }); + + suggestionDiff.$on('apply', ({ suggestionId, callback }) => { + this.$emit('apply', { suggestionId, callback, flashContainer: this.$el }); + }); + + return suggestionDiff; + }, + reset() { + // resets the container HTML (replaces it with the updated noteHTML) + // calls `renderSuggestions` once the updated noteHTML is added to the DOM + + this.$refs.container.innerHTML = this.noteHtml; + this.isRendered = false; + this.renderSuggestions(); + this.$nextTick(() => this.renderSuggestions()); + }, + }, +}; +</script> + +<template> + <div> + <div class="flash-container mt-3"></div> + <div v-show="isRendered" ref="container" v-html="noteHtml"></div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index a6d2cecdf7e..4572caa907b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -37,6 +37,16 @@ export default { required: false, default: false, }, + tagContent: { + type: String, + required: false, + default: '', + }, + cursorOffset: { + type: Number, + required: false, + default: 0, + }, }, }; </script> @@ -45,8 +55,10 @@ export default { <button v-gl-tooltip :data-md-tag="tag" + :data-md-cursor-offset="cursorOffset" :data-md-select="tagSelect" :data-md-block="tagBlock" + :data-md-tag-content="tagContent" :data-md-prepend="prepend" :title="buttonTitle" :aria-label="buttonTitle" diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 2b110e23fb8..5609a2086e6 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -277,6 +277,27 @@ } } +.md-suggestion-diff { + display: table !important; + border: 1px solid $border-color !important; +} + +.md-suggestion-header { + height: $suggestion-header-height; + display: flex; + align-items: center; + justify-content: space-between; + background-color: $gray-light; + border: 1px solid $border-color; + padding: $gl-padding; + border-radius: $border-radius-default $border-radius-default 0 0; + + svg { + vertical-align: middle; + margin-bottom: 3px; + } +} + @include media-breakpoint-down(xs) { .atwho-view-ul { width: 350px; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a92481b3ebb..c0bba30944a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -252,6 +252,7 @@ $browserScrollbarSize: 10px; * Misc */ $header-height: 40px; +$suggestion-header-height: 46px; $ide-statusbar-height: 25px; $fixed-layout-width: 1280px; $limited-layout-width: 990px; diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index c61b9fabe9e..4b0f0b8255c 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -12,7 +12,7 @@ module PreviewMarkdown when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } when 'snippets' then { skip_project_check: true } when 'groups' then { group: group } - when 'projects' then { issuable_state_filter_enabled: true } + when 'projects' then projects_filter_params else {} end @@ -22,9 +22,17 @@ module PreviewMarkdown body: view_context.markdown(result[:text], markdown_params), references: { users: result[:users], + suggestions: result[:suggestions], commands: view_context.markdown(result[:commands]) } } end + + def projects_filter_params + { + issuable_state_filter_enabled: true, + suggestions_filter_enabled: params[:preview_suggestions].present? + } + end # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index eb315058c3a..f2cad09e779 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -26,6 +26,10 @@ module Noteable DiscussionNote.noteable_types.include?(base_class_name) end + def supports_suggestion? + false + end + def discussions_rendered_on_frontend? false end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index c32008aa9c7..279603496b0 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -66,10 +66,23 @@ class DiffNote < Note self.original_position.diff_refs == diff_refs end + def supports_suggestion? + return false unless noteable.supports_suggestion? && on_text? + # We don't want to trigger side-effects of `diff_file` call. + return false unless file = fetch_diff_file + return false unless line = file.line_for_position(self.original_position) + + line&.suggestible? + end + def discussion_first_note? self == discussion.first_note end + def banzai_render_context(field) + super.merge(suggestions_filter_enabled: supports_suggestion?) + end + private def enqueue_diff_file_creation_job diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index a13cac73d04..8052a54c504 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -363,6 +363,11 @@ class MergeRequest < ActiveRecord::Base end end + def supports_suggestion? + # Should be `true` when removing the FF. + Suggestion.feature_enabled? + end + # Calls `MergeWorker` to proceed with the merge process and # updates `merge_jid` with the MergeWorker#jid. # This helps tracking enqueued and ongoing merge jobs. diff --git a/app/models/note.rb b/app/models/note.rb index 17c7d97fa0a..becf14e9785 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -69,6 +69,12 @@ class Note < ActiveRecord::Base belongs_to :last_edited_by, class_name: 'User' has_many :todos + + # The delete_all definition is required here in order + # to generate the correct DELETE sql for + # suggestions.delete_all calls + has_many :suggestions, -> { order(:relative_order) }, + inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :system_note_metadata has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id @@ -110,7 +116,7 @@ class Note < ActiveRecord::Base scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, - :system_note_metadata, :note_diff_file) + :system_note_metadata, :note_diff_file, :suggestions) end scope :with_notes_filter, -> (notes_filter) do @@ -226,6 +232,10 @@ class Note < ActiveRecord::Base Gitlab::HookData::NoteBuilder.new(self).build end + def supports_suggestion? + false + end + def for_commit? noteable_type == "Commit" end diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb new file mode 100644 index 00000000000..cec5ea30f9d --- /dev/null +++ b/app/models/suggestion.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class Suggestion < ApplicationRecord + FEATURE_FLAG = :diff_suggestions + + belongs_to :note, inverse_of: :suggestions + validates :note, presence: true + validates :commit_id, presence: true, if: :applied? + + delegate :original_position, :position, :diff_file, + :noteable, to: :note + + def self.feature_enabled? + Feature.enabled?(FEATURE_FLAG) + end + + def project + noteable.source_project + end + + def branch + noteable.source_branch + end + + # For now, suggestions only serve as a way to send patches that + # will change a single line (being able to apply multiple in the same place), + # which explains `from_line` and `to_line` being the same line. + # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 + # when allowing multi-line suggestions. + def from_line + position.new_line + end + alias_method :to_line, :from_line + + def from_original_line + original_position.new_line + end + alias_method :to_original_line, :from_original_line + + # `from_line_index` and `to_line_index` represents diff/blob line numbers in + # index-like way (N-1). + def from_line_index + from_line - 1 + end + alias_method :to_line_index, :from_line_index + + def appliable? + return false unless note.supports_suggestion? + + !applied? && + noteable.opened? && + different_content? && + note.active? + end + + private + + def different_content? + from_content != to_content + end +end diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb new file mode 100644 index 00000000000..301b7d965f5 --- /dev/null +++ b/app/policies/suggestion_policy.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SuggestionPolicy < BasePolicy + delegate { @subject.project } + + condition(:can_push_to_branch) do + Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch) + end + + rule { can_push_to_branch }.enable :apply_suggestion +end diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb index 942714b7787..bfef6d3bde8 100644 --- a/app/serializers/diff_line_entity.rb +++ b/app/serializers/diff_line_entity.rb @@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity expose :rich_text do |line| ERB::Util.html_escape(line.rich_text || line.text) end + + expose :suggestible?, as: :can_receive_suggestion end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index f33a1654d5e..9731b52f1ad 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity end end + expose :supports_suggestion?, as: :can_receive_suggestion + private delegate :current_user, to: :request diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index c6d27817411..1d3b59eb1b7 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note end end + expose :suggestions, using: SuggestionEntity expose :resolved?, as: :resolved expose :resolvable?, as: :resolvable diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb new file mode 100644 index 00000000000..4d0d4da10be --- /dev/null +++ b/app/serializers/suggestion_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SuggestionEntity < API::Entities::Suggestion + include RequestAwareEntity + + expose :current_user do + expose :can_apply do |suggestion| + Ability.allowed?(current_user, :apply_suggestion, suggestion) + end + end + + private + + def current_user + request.current_user + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index e03789e3ca9..c4546f30235 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -36,6 +36,7 @@ module Notes if !only_commands && note.save todo_service.new_note(note, current_user) clear_noteable_diffs_cache(note) + Suggestions::CreateService.new(note).execute end if command_params.present? diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 35db409eb27..d2052bed646 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -14,6 +14,17 @@ module Notes TodoService.new.update_note(note, current_user, old_mentioned_users) end + if note.supports_suggestion? + Suggestion.transaction do + note.suggestions.delete_all + Suggestions::CreateService.new(note).execute + end + + # We need to refresh the previous suggestions call cache + # in order to get the new records. + note.reload + end + note end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index de8757006f1..a449a5dc3e9 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService def execute text, commands = explain_quick_actions(params[:text]) users = find_user_references(text) + suggestions = find_suggestions(text) success( text: text, users: users, + suggestions: suggestions, commands: commands.join(' '), markdown_engine: markdown_engine ) @@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService extractor.users.map(&:username) end + def find_suggestions(text) + return [] unless params[:preview_suggestions] + + Banzai::SuggestionsParser.parse(text) + end + def find_commands_target QuickActions::TargetService .new(project, current_user) diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb new file mode 100644 index 00000000000..d931d528c86 --- /dev/null +++ b/app/services/suggestions/apply_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Suggestions + class ApplyService < ::BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(suggestion) + unless suggestion.appliable? + return error('Suggestion is not appliable') + end + + params = file_update_params(suggestion) + result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute + + if result[:status] == :success + suggestion.update(commit_id: result[:result], applied: true) + end + + result + end + + private + + def file_update_params(suggestion) + diff_file = suggestion.diff_file + + file_path = diff_file.file_path + branch_name = suggestion.noteable.source_branch + file_content = new_file_content(suggestion) + commit_message = "Apply suggestion to #{file_path}" + + { + file_path: file_path, + branch_name: branch_name, + start_branch: branch_name, + commit_message: commit_message, + file_content: file_content + } + end + + def new_file_content(suggestion) + range = suggestion.from_line_index..suggestion.to_line_index + blob = suggestion.diff_file.new_blob + + blob.load_all_data! + content = blob.data.lines + content[range] = suggestion.to_content + + content.join + end + end +end diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb new file mode 100644 index 00000000000..77e958cbe0c --- /dev/null +++ b/app/services/suggestions/create_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Suggestions + class CreateService + def initialize(note) + @note = note + end + + def execute + return unless @note.supports_suggestion? + + suggestions = Banzai::SuggestionsParser.parse(@note.note) + + # For single line suggestion we're only looking forward to + # change the line receiving the comment. Though, in + # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310 + # we'll introduce a ```suggestion:L<x>-<y>, so this will + # slightly change. + comment_line = @note.position.new_line + + rows = + suggestions.map.with_index do |suggestion, index| + from_content = changing_lines(comment_line, comment_line) + + # The parsed suggestion doesn't have information about the correct + # ending characters (we may have a line break, or not), so we take + # this information from the last line being changed (last + # characters). + endline_chars = line_break_chars(from_content.lines.last) + to_content = "#{suggestion}#{endline_chars}" + + { + note_id: @note.id, + from_content: from_content, + to_content: to_content, + relative_order: index + } + end + + rows.in_groups_of(100, false) do |rows| + Gitlab::Database.bulk_insert('suggestions', rows) + end + end + + private + + def changing_lines(from_line, to_line) + @note.diff_file.new_blob_lines_between(from_line, to_line).join + end + + def line_break_chars(line) + match = /\r\n|\r|\n/.match(line) + match[0] if match + end + end +end diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index c178206dda4..3f2e59d05e3 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -67,6 +67,7 @@ noteable_data: serialize_issuable(@merge_request), noteable_type: 'MergeRequest', target_type: 'merge_request', + help_page_path: nil, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} } #commits.commits.tab-pane @@ -76,6 +77,7 @@ = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) #js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?, endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters), + help_page_path: nil, current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json, project_path: project_path(@merge_request.project), changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } } |