diff options
Diffstat (limited to 'app/assets/javascripts/merge_conflicts')
16 files changed, 1012 insertions, 189 deletions
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js deleted file mode 100644 index 6eaabbb3519..00000000000 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ /dev/null @@ -1,115 +0,0 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on -// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml -// for its template. -/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ - -import { debounce } from 'lodash'; -import Vue from 'vue'; -import { deprecatedCreateFlash as flash } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.diffFileEditor = Vue.extend({ - props: { - file: { - type: Object, - required: true, - }, - onCancelDiscardConfirmation: { - type: Function, - required: true, - }, - onAcceptDiscardConfirmation: { - type: Function, - required: true, - }, - }, - data() { - return { - saved: false, - fileLoaded: false, - originalContent: '', - }; - }, - computed: { - classObject() { - return { - saved: this.saved, - }; - }, - }, - watch: { - 'file.showEditor': function showEditorWatcher(val) { - this.resetEditorContent(); - - if (!val || this.fileLoaded) { - return; - } - - this.loadEditor(); - }, - }, - mounted() { - if (this.file.loadEditor) { - this.loadEditor(); - } - }, - methods: { - loadEditor() { - const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); - const DataPromise = axios.get(this.file.content_path); - - Promise.all([EditorPromise, DataPromise]) - .then( - ([ - { default: EditorLite }, - { - data: { content, new_path: path }, - }, - ]) => { - const contentEl = this.$el.querySelector('.editor'); - - this.originalContent = content; - this.fileLoaded = true; - - this.editor = new EditorLite().createInstance({ - el: contentEl, - blobPath: path, - blobContent: content, - }); - this.editor.onDidChangeModelContent( - debounce(this.saveDiffResolution.bind(this), 250), - ); - }, - ) - .catch(() => { - flash(__('An error occurred while loading the file')); - }); - }, - saveDiffResolution() { - this.saved = true; - - // This probably be better placed in the data provider - /* eslint-disable vue/no-mutating-props */ - this.file.content = this.editor.getValue(); - this.file.resolveEditChanged = this.file.content !== this.originalContent; - this.file.promptDiscardConfirmation = false; - /* eslint-enable vue/no-mutating-props */ - }, - resetEditorContent() { - if (this.fileLoaded) { - this.editor.setValue(this.originalContent); - } - }, - cancelDiscardConfirmation(file) { - this.onCancelDiscardConfirmation(file); - }, - acceptDiscardConfirmation(file) { - this.onAcceptDiscardConfirmation(file); - }, - }, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue new file mode 100644 index 00000000000..2c7c8038af5 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -0,0 +1,128 @@ +<script> +import { debounce } from 'lodash'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; + +export default { + props: { + file: { + type: Object, + required: true, + }, + onCancelDiscardConfirmation: { + type: Function, + required: true, + }, + onAcceptDiscardConfirmation: { + type: Function, + required: true, + }, + }, + data() { + return { + saved: false, + fileLoaded: false, + originalContent: '', + }; + }, + computed: { + classObject() { + return { + saved: this.saved, + }; + }, + }, + watch: { + 'file.showEditor': function showEditorWatcher(val) { + this.resetEditorContent(); + + if (!val || this.fileLoaded) { + return; + } + + this.loadEditor(); + }, + }, + mounted() { + if (this.file.loadEditor) { + this.loadEditor(); + } + }, + methods: { + loadEditor() { + const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); + const DataPromise = axios.get(this.file.content_path); + + Promise.all([EditorPromise, DataPromise]) + .then( + ([ + { default: EditorLite }, + { + data: { content, new_path: path }, + }, + ]) => { + const contentEl = this.$el.querySelector('.editor'); + + this.originalContent = content; + this.fileLoaded = true; + + this.editor = new EditorLite().createInstance({ + el: contentEl, + blobPath: path, + blobContent: content, + }); + this.editor.onDidChangeModelContent(debounce(this.saveDiffResolution.bind(this), 250)); + }, + ) + .catch(() => { + flash(__('An error occurred while loading the file')); + }); + }, + saveDiffResolution() { + this.saved = true; + + // This probably be better placed in the data provider + /* eslint-disable vue/no-mutating-props */ + this.file.content = this.editor.getValue(); + this.file.resolveEditChanged = this.file.content !== this.originalContent; + this.file.promptDiscardConfirmation = false; + /* eslint-enable vue/no-mutating-props */ + }, + resetEditorContent() { + if (this.fileLoaded) { + this.editor.setValue(this.originalContent); + } + }, + cancelDiscardConfirmation(file) { + this.onCancelDiscardConfirmation(file); + }, + acceptDiscardConfirmation(file) { + this.onAcceptDiscardConfirmation(file); + }, + }, +}; +</script> +<template> + <div v-show="file.showEditor" class="diff-editor-wrap"> + <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert-wrap"> + <div class="discard-changes-alert"> + {{ __('Are you sure you want to discard your changes?') }} + <div class="discard-actions"> + <button + class="btn btn-sm btn-danger-secondary gl-button" + @click="acceptDiscardConfirmation(file)" + > + {{ __('Discard changes') }} + </button> + <button class="btn btn-default btn-sm gl-button" @click="cancelDiscardConfirmation(file)"> + {{ __('Cancel') }} + </button> + </div> + </div> + </div> + <div :class="classObject" class="editor-wrap"> + <div class="editor" style="height: 350px" data-editor-loading="true"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js deleted file mode 100644 index 47214e288ae..00000000000 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js +++ /dev/null @@ -1,22 +0,0 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on -// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml -// for its template. -/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ - -import Vue from 'vue'; -import actionsMixin from '../mixins/line_conflict_actions'; -import utilsMixin from '../mixins/line_conflict_utils'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.inlineConflictLines = Vue.extend({ - mixins: [utilsMixin, actionsMixin], - props: { - file: { - type: Object, - required: true, - }, - }, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue new file mode 100644 index 00000000000..519fd53af1e --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -0,0 +1,47 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import actionsMixin from '../mixins/line_conflict_actions'; +import utilsMixin from '../mixins/line_conflict_utils'; + +export default { + directives: { + SafeHtml, + }, + mixins: [utilsMixin, actionsMixin], + props: { + file: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <table class="diff-wrap-lines code code-commit js-syntax-highlight"> + <tr + v-for="line in file.inlineLines" + :key="(line.isHeader ? line.id : line.new_line) + line.richText" + class="line_holder diff-inline" + > + <template v-if="line.isHeader"> + <td :class="lineCssClass(line)" class="diff-line-num header"></td> + <td :class="lineCssClass(line)" class="diff-line-num header"></td> + <td :class="lineCssClass(line)" class="line_content header"> + <strong>{{ line.richText }}</strong> + <button class="btn" @click="handleSelected(file, line.id, line.section)"> + {{ line.buttonTitle }} + </button> + </td> + </template> + <template v-else> + <td :class="lineCssClass(line)" class="diff-line-num new_line"> + <a>{{ line.new_line }}</a> + </td> + <td :class="lineCssClass(line)" class="diff-line-num old_line"> + <a>{{ line.old_line }}</a> + </td> + <td v-safe-html="line.richText" :class="lineCssClass(line)" class="line_content"></td> + </template> + </tr> + </table> +</template> diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js deleted file mode 100644 index 1d5946cd78a..00000000000 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable no-param-reassign */ - -import Vue from 'vue'; -import actionsMixin from '../mixins/line_conflict_actions'; -import utilsMixin from '../mixins/line_conflict_utils'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.parallelConflictLines = Vue.extend({ - mixins: [utilsMixin, actionsMixin], - props: { - file: { - type: Object, - required: true, - }, - }, - // This is a true violation of @gitlab/no-runtime-template-compiler, as it - // has a template string. - // eslint-disable-next-line @gitlab/no-runtime-template-compiler - template: ` - <table class="diff-wrap-lines code js-syntax-highlight"> - <tr class="line_holder parallel" v-for="section in file.parallelLines"> - <template v-for="line in section"> - <td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td> - <td class="line_content header" :class="lineCssClass(line)" v-if="line.isHeader"> - <strong>{{line.richText}}</strong> - <button class="btn" @click="handleSelected(file, line.id, line.section)">{{line.buttonTitle}}</button> - </td> - <td class="diff-line-num old_line" :class="lineCssClass(line)" v-if="!line.isHeader">{{line.lineNumber}}</td> - <td class="line_content parallel" :class="lineCssClass(line)" v-if="!line.isHeader" v-html="line.richText"></td> - </template> - </tr> - </table> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue new file mode 100644 index 00000000000..e66f641f70d --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -0,0 +1,47 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import actionsMixin from '../mixins/line_conflict_actions'; +import utilsMixin from '../mixins/line_conflict_utils'; + +export default { + directives: { + SafeHtml, + }, + mixins: [utilsMixin, actionsMixin], + props: { + file: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <!-- Unfortunately there isn't a good key for these sections --> + <!-- eslint-disable vue/require-v-for-key --> + <table class="diff-wrap-lines code js-syntax-highlight"> + <tr v-for="section in file.parallelLines" class="line_holder parallel"> + <template v-for="line in section"> + <template v-if="line.isHeader"> + <td class="diff-line-num header" :class="lineCssClass(line)"></td> + <td class="line_content header" :class="lineCssClass(line)"> + <strong>{{ line.richText }}</strong> + <button class="btn" @click="handleSelected(file, line.id, line.section)"> + {{ line.buttonTitle }} + </button> + </td> + </template> + <template v-else> + <td class="diff-line-num old_line" :class="lineCssClass(line)"> + {{ line.lineNumber }} + </td> + <td + v-safe-html="line.richText" + class="line_content parallel" + :class="lineCssClass(line)" + ></td> + </template> + </template> + </tr> + </table> +</template> diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js new file mode 100644 index 00000000000..6f3ee339e36 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/constants.js @@ -0,0 +1,20 @@ +import { s__ } from '~/locale'; + +export const CONFLICT_TYPES = { + TEXT: 'text', + TEXT_EDITOR: 'text-editor', +}; + +export const VIEW_TYPES = { + INLINE: 'inline', + PARALLEL: 'parallel', +}; + +export const EDIT_RESOLVE_MODE = 'edit'; +export const INTERACTIVE_RESOLVE_MODE = 'interactive'; +export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE; + +export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes'); +export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes'); +export const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours'); +export const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs'); diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue new file mode 100644 index 00000000000..16a7cfb2ba8 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -0,0 +1,217 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import DiffFileEditor from './components/diff_file_editor.vue'; +import InlineConflictLines from './components/inline_conflict_lines.vue'; +import ParallelConflictLines from './components/parallel_conflict_lines.vue'; + +/** + * NOTE: Most of this component is directly using $root, rather than props or a better data store. + * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should + * be replaced with GitLab UI components. + * + * We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner + * and are going to clean it up as part of: + * + * https://gitlab.com/gitlab-org/gitlab/-/issues/321090 + * + */ +export default { + components: { + GlSprintf, + FileIcon, + DiffFileEditor, + InlineConflictLines, + ParallelConflictLines, + }, + inject: ['mergeRequestPath', 'sourceBranchPath'], + i18n: { + commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'), + resolveInfo: __( + 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}', + ), + }, +}; +</script> +<template> + <div id="conflicts"> + <div v-if="$root.isLoading" class="loading"> + <div class="spinner spinner-md"></div> + </div> + <div v-if="$root.hasError" class="nothing-here-block"> + {{ $root.conflictsData.errorMessage }} + </div> + <template v-if="!$root.isLoading && !$root.hasError"> + <div class="content-block oneline-block files-changed"> + <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons"> + <div class="btn-group"> + <button + :class="{ active: !$root.isParallel }" + class="btn gl-button" + @click="$root.handleViewTypeChange('inline')" + > + {{ __('Inline') }} + </button> + <button + :class="{ active: $root.isParallel }" + class="btn gl-button" + @click="$root.handleViewTypeChange('parallel')" + > + {{ __('Side-by-side') }} + </button> + </div> + </div> + <div class="js-toggle-container"> + <div class="commit-stat-summary"> + <gl-sprintf :message="$options.i18n.commitStatSummary"> + <template #conflict> + <strong class="cred"> + {{ $root.conflictsCountText }} + </strong> + </template> + <template #sourceBranch> + <strong class="ref-name"> + {{ $root.conflictsData.sourceBranch }} + </strong> + </template> + <template #targetBranch> + <strong class="ref-name"> + {{ $root.conflictsData.targetBranch }} + </strong> + </template> + </gl-sprintf> + </div> + </div> + </div> + <div class="files-wrapper"> + <div class="files"> + <div + v-for="file in $root.conflictsData.files" + :key="file.blobPath" + class="diff-file file-holder conflict" + > + <div class="js-file-title file-title file-title-flex-parent cursor-default"> + <div class="file-header-content"> + <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" /> + <strong class="file-title-name">{{ file.filePath }}</strong> + </div> + <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start"> + <div v-if="file.type === 'text'" class="btn-group gl-mr-3"> + <button + :class="{ active: file.resolveMode === 'interactive' }" + class="btn gl-button" + type="button" + @click="$root.onClickResolveModeButton(file, 'interactive')" + > + {{ __('Interactive mode') }} + </button> + <button + :class="{ active: file.resolveMode === 'edit' }" + class="btn gl-button" + type="button" + @click="$root.onClickResolveModeButton(file, 'edit')" + > + {{ __('Edit inline') }} + </button> + </div> + <a :href="file.blobPath" class="btn gl-button view-file"> + <gl-sprintf :message="__('View file @ %{commitSha}')"> + <template #commitSha> + {{ $root.conflictsData.shortCommitSha }} + </template> + </gl-sprintf> + </a> + </div> + </div> + <div class="diff-content diff-wrap-lines"> + <div + v-show=" + !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text' + " + class="file-content" + > + <inline-conflict-lines :file="file" /> + </div> + <div + v-show=" + $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text' + " + class="file-content" + > + <parallel-conflict-lines :file="file" /> + </div> + <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'"> + <diff-file-editor + :file="file" + :on-accept-discard-confirmation="$root.acceptDiscardConfirmation" + :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation" + /> + </div> + </div> + </div> + </div> + </div> + <hr /> + <div class="resolve-conflicts-form"> + <div class="form-group row"> + <div class="col-md-4"> + <h4> + {{ __('Resolve conflicts on source branch') }} + </h4> + <div class="resolve-info"> + <gl-sprintf :message="$options.i18n.resolveInfo"> + <template #use_ours> + <code>{{ s__('MergeConflict|Use ours') }}</code> + </template> + <template #use_theirs> + <code>{{ s__('MergeConflict|Use theirs') }}</code> + </template> + <template #branch_name> + <a class="ref-name" :href="sourceBranchPath"> + {{ $root.conflictsData.sourceBranch }} + </a> + </template> + </gl-sprintf> + </div> + </div> + <div class="col-md-8"> + <label class="label-bold" for="commit-message"> + {{ __('Commit message') }} + </label> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + id="commit-message" + v-model="$root.conflictsData.commitMessage" + class="form-control js-commit-message" + rows="5" + ></textarea> + </div> + </div> + </div> + <div class="form-group row"> + <div class="offset-md-4 col-md-8"> + <div class="row"> + <div class="col-6"> + <button + :disabled="!$root.readyToCommit" + class="btn gl-button btn-success js-submit-button" + type="button" + @click="$root.commit()" + > + <span>{{ $root.commitButtonText }}</span> + </button> + </div> + <div class="col-6 text-right"> + <a :href="mergeRequestPath" class="gl-button btn btn-default"> + {{ __('Cancel') }} + </a> + </div> + </div> + </div> + </div> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index e3972b8b574..4b73dd317cd 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,19 +1,12 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it -// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its -// template. -/* eslint-disable @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; import { __ } from '~/locale'; -import FileIcon from '~/vue_shared/components/file_icon.vue'; import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; import syntaxHighlight from '../syntax_highlight'; +import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue'; import MergeConflictsService from './merge_conflict_service'; -import './components/diff_file_editor'; -import './components/inline_conflict_lines'; -import './components/parallel_conflict_lines'; export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; @@ -24,15 +17,15 @@ export default function initMergeConflicts() { resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath, }); + const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset; + initIssuableSidebar(); - gl.MergeConflictsResolverApp = new Vue({ - el: '#conflicts', - components: { - FileIcon, - 'diff-file-editor': gl.mergeConflicts.diffFileEditor, - 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines, - 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines, + return new Vue({ + el: conflictsEl, + provide: { + sourceBranchPath, + mergeRequestPath, }, data: mergeConflictsStore.state, computed: { @@ -103,5 +96,8 @@ export default function initMergeConflicts() { }); }, }, + render(createElement) { + return createElement(MergeConflictsResolverApp); + }, }); } diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js new file mode 100644 index 00000000000..8036e90c58c --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/actions.js @@ -0,0 +1,120 @@ +import Cookies from 'js-cookie'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants'; +import { decorateFiles, restoreFileLinesState, markLine } from '../utils'; +import * as types from './mutation_types'; + +export const fetchConflictsData = async ({ commit, dispatch }, conflictsPath) => { + commit(types.SET_LOADING_STATE, true); + try { + const { data } = await axios.get(conflictsPath); + if (data.type === 'error') { + commit(types.SET_FAILED_REQUEST, data.message); + } else { + dispatch('setConflictsData', data); + } + } catch (e) { + commit(types.SET_FAILED_REQUEST); + } + commit(types.SET_LOADING_STATE, false); +}; + +export const setConflictsData = async ({ commit }, data) => { + const files = decorateFiles(data.files); + commit(types.SET_CONFLICTS_DATA, { ...data, files }); +}; + +export const submitResolvedConflicts = async ({ commit, getters }, resolveConflictsPath) => { + commit(types.SET_SUBMIT_STATE, true); + try { + const { data } = await axios.post(resolveConflictsPath, getters.getCommitData); + window.location.assign(data.redirect_to); + } catch (e) { + commit(types.SET_SUBMIT_STATE, false); + createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') }); + } +}; + +export const setLoadingState = ({ commit }, isLoading) => { + commit(types.SET_LOADING_STATE, isLoading); +}; + +export const setErrorState = ({ commit }, hasError) => { + commit(types.SET_ERROR_STATE, hasError); +}; + +export const setFailedRequest = ({ commit }, message) => { + commit(types.SET_FAILED_REQUEST, message); +}; + +export const setViewType = ({ commit }, viewType) => { + commit(types.SET_VIEW_TYPE, viewType); + Cookies.set('diff_view', viewType); +}; + +export const setSubmitState = ({ commit }, isSubmitting) => { + commit(types.SET_SUBMIT_STATE, isSubmitting); +}; + +export const updateCommitMessage = ({ commit }, commitMessage) => { + commit(types.UPDATE_CONFLICTS_DATA, { commitMessage }); +}; + +export const setFileResolveMode = ({ commit, state, getters }, { file, mode }) => { + const index = getters.getFileIndex(file); + const updated = { ...state.conflictsData.files[index] }; + if (mode === INTERACTIVE_RESOLVE_MODE) { + updated.showEditor = false; + } else if (mode === EDIT_RESOLVE_MODE) { + // Restore Interactive mode when switching to Edit mode + updated.showEditor = true; + updated.loadEditor = true; + updated.resolutionData = {}; + + const { inlineLines, parallelLines } = restoreFileLinesState(updated); + updated.parallelLines = parallelLines; + updated.inlineLines = inlineLines; + } + updated.resolveMode = mode; + commit(types.UPDATE_FILE, { file: updated, index }); +}; + +export const setPromptConfirmationState = ( + { commit, state, getters }, + { file, promptDiscardConfirmation }, +) => { + const index = getters.getFileIndex(file); + const updated = { ...state.conflictsData.files[index], promptDiscardConfirmation }; + commit(types.UPDATE_FILE, { file: updated, index }); +}; + +export const handleSelected = ({ commit, state, getters }, { file, line: { id, section } }) => { + const index = getters.getFileIndex(file); + const updated = { ...state.conflictsData.files[index] }; + updated.resolutionData = { ...updated.resolutionData, [id]: section }; + + updated.inlineLines = file.inlineLines.map((line) => { + if (id === line.id && (line.hasConflict || line.isHeader)) { + return markLine(line, section); + } + return line; + }); + + updated.parallelLines = file.parallelLines.map((lines) => { + let left = { ...lines[0] }; + let right = { ...lines[1] }; + const hasSameId = right.id === id || left.id === id; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (hasSameId && (isLeftMatch || isRightMatch)) { + left = markLine(left, section); + right = markLine(right, section); + } + return [left, right]; + }); + + commit(types.UPDATE_FILE, { file: updated, index }); +}; diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js new file mode 100644 index 00000000000..03e425fb478 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/getters.js @@ -0,0 +1,117 @@ +import { s__ } from '~/locale'; +import { CONFLICT_TYPES, EDIT_RESOLVE_MODE, INTERACTIVE_RESOLVE_MODE } from '../constants'; + +export const getConflictsCount = (state) => { + if (!state.conflictsData.files.length) { + return 0; + } + + const { files } = state.conflictsData; + let count = 0; + + files.forEach((file) => { + if (file.type === CONFLICT_TYPES.TEXT) { + file.sections.forEach((section) => { + if (section.conflict) { + count += 1; + } + }); + } else { + count += 1; + } + }); + + return count; +}; + +export const getConflictsCountText = (state, getters) => { + const count = getters.getConflictsCount; + const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict'); + + return `${count} ${text}`; +}; + +export const isReadyToCommit = (state) => { + const { files } = state.conflictsData; + const hasCommitMessage = state.conflictsData.commitMessage.trim().length; + let unresolved = 0; + + for (let i = 0, l = files.length; i < l; i += 1) { + const file = files[i]; + + if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) { + let numberConflicts = 0; + const resolvedConflicts = Object.keys(file.resolutionData).length; + + // We only check for conflicts type 'text' + // since conflicts `text_editor` can´t be resolved in interactive mode + if (file.type === CONFLICT_TYPES.TEXT) { + for (let j = 0, k = file.sections.length; j < k; j += 1) { + if (file.sections[j].conflict) { + numberConflicts += 1; + } + } + + if (resolvedConflicts !== numberConflicts) { + unresolved += 1; + } + } + } else if (file.resolveMode === EDIT_RESOLVE_MODE) { + // Unlikely to happen since switching to Edit mode saves content automatically. + // Checking anyway in case the save strategy changes in the future + if (!file.content) { + unresolved += 1; + // eslint-disable-next-line no-continue + continue; + } + } + } + + return !state.isSubmitting && hasCommitMessage && !unresolved; +}; + +export const getCommitButtonText = (state) => { + const initial = s__('MergeConflict|Commit to source branch'); + const inProgress = s__('MergeConflict|Committing...'); + + return state.isSubmitting ? inProgress : initial; +}; + +export const getCommitData = (state) => { + let commitData = {}; + + commitData = { + commit_message: state.conflictsData.commitMessage, + files: [], + }; + + state.conflictsData.files.forEach((file) => { + const addFile = { + old_path: file.old_path, + new_path: file.new_path, + }; + + if (file.type === CONFLICT_TYPES.TEXT) { + // Submit only one data for type of editing + if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) { + addFile.sections = file.resolutionData; + } else if (file.resolveMode === EDIT_RESOLVE_MODE) { + addFile.content = file.content; + } + } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) { + addFile.content = file.content; + } + + commitData.files.push(addFile); + }); + + return commitData; +}; + +export const fileTextTypePresent = (state) => { + return state.conflictsData?.files.some((f) => f.type === CONFLICT_TYPES.TEXT); +}; + +export const getFileIndex = (state) => ({ blobPath }) => { + return state.conflictsData.files.findIndex((f) => f.blobPath === blobPath); +}; diff --git a/app/assets/javascripts/merge_conflicts/store/index.js b/app/assets/javascripts/merge_conflicts/store/index.js new file mode 100644 index 00000000000..18e3351ed13 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state, + getters, + actions, + mutations, + }); diff --git a/app/assets/javascripts/merge_conflicts/store/mutation_types.js b/app/assets/javascripts/merge_conflicts/store/mutation_types.js new file mode 100644 index 00000000000..ab80f8e52ad --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/mutation_types.js @@ -0,0 +1,8 @@ +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_ERROR_STATE = 'SET_ERROR_STATE'; +export const SET_FAILED_REQUEST = 'SET_FAILED_REQUEST'; +export const SET_VIEW_TYPE = 'SET_VIEW_TYPE'; +export const SET_SUBMIT_STATE = 'SET_SUBMIT_STATE'; +export const SET_CONFLICTS_DATA = 'SET_CONFLICTS_DATA'; +export const UPDATE_FILE = 'UPDATE_FILE'; +export const UPDATE_CONFLICTS_DATA = 'UPDATE_CONFLICTS_DATA'; diff --git a/app/assets/javascripts/merge_conflicts/store/mutations.js b/app/assets/javascripts/merge_conflicts/store/mutations.js new file mode 100644 index 00000000000..2cee55319eb --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/mutations.js @@ -0,0 +1,40 @@ +import { VIEW_TYPES } from '../constants'; +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE]: (state, value) => { + state.isLoading = value; + }, + [types.SET_ERROR_STATE]: (state, value) => { + state.hasError = value; + }, + [types.SET_FAILED_REQUEST]: (state, value) => { + state.hasError = true; + state.conflictsData.errorMessage = value; + }, + [types.SET_VIEW_TYPE]: (state, value) => { + state.diffView = value; + state.isParallel = value === VIEW_TYPES.PARALLEL; + }, + [types.SET_SUBMIT_STATE]: (state, value) => { + state.isSubmitting = value; + }, + [types.SET_CONFLICTS_DATA]: (state, data) => { + state.conflictsData = { + files: data.files, + commitMessage: data.commit_message, + sourceBranch: data.source_branch, + targetBranch: data.target_branch, + shortCommitSha: data.commit_sha.slice(0, 7), + }; + }, + [types.UPDATE_CONFLICTS_DATA]: (state, payload) => { + state.conflictsData = { + ...state.conflictsData, + ...payload, + }; + }, + [types.UPDATE_FILE]: (state, { file, index }) => { + state.conflictsData.files.splice(index, 1, file); + }, +}; diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js new file mode 100644 index 00000000000..8f700f58e54 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/state.js @@ -0,0 +1,13 @@ +import Cookies from 'js-cookie'; +import { VIEW_TYPES } from '../constants'; + +const diffViewType = Cookies.get('diff_view'); + +export default () => ({ + isLoading: true, + hasError: false, + isSubmitting: false, + isParallel: diffViewType === VIEW_TYPES.PARALLEL, + diffViewType, + conflictsData: {}, +}); diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js new file mode 100644 index 00000000000..e42703ef0a5 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/utils.js @@ -0,0 +1,228 @@ +import { + ORIGIN_HEADER_TEXT, + ORIGIN_BUTTON_TITLE, + HEAD_HEADER_TEXT, + HEAD_BUTTON_TITLE, + DEFAULT_RESOLVE_MODE, + CONFLICT_TYPES, +} from './constants'; + +export const getFilePath = (file) => { + const { old_path, new_path } = file; + // eslint-disable-next-line babel/camelcase + return old_path === new_path ? new_path : `${old_path} → ${new_path}`; +}; + +export const checkLineLengths = ({ left, right }) => { + const wLeft = [...left]; + const wRight = [...right]; + if (left.length !== right.length) { + if (left.length > right.length) { + const diff = left.length - right.length; + for (let i = 0; i < diff; i += 1) { + wRight.push({ lineType: 'emptyLine', richText: '' }); + } + } else { + const diff = right.length - left.length; + for (let i = 0; i < diff; i += 1) { + wLeft.push({ lineType: 'emptyLine', richText: '' }); + } + } + } + return { left: wLeft, right: wRight }; +}; + +export const getHeadHeaderLine = (id) => { + return { + id, + richText: HEAD_HEADER_TEXT, + buttonTitle: HEAD_BUTTON_TITLE, + type: 'new', + section: 'head', + isHeader: true, + isHead: true, + isSelected: false, + isUnselected: false, + }; +}; + +export const decorateLineForInlineView = (line, id, conflict) => { + const { type } = line; + return { + id, + hasConflict: conflict, + isHead: type === 'new', + isOrigin: type === 'old', + hasMatch: type === 'match', + richText: line.rich_text, + isSelected: false, + isUnselected: false, + }; +}; + +export const getLineForParallelView = (line, id, lineType, isHead) => { + const { old_line, new_line, rich_text } = line; + const hasConflict = lineType === 'conflict'; + + return { + id, + lineType, + hasConflict, + isHead: hasConflict && isHead, + isOrigin: hasConflict && !isHead, + hasMatch: lineType === 'match', + // eslint-disable-next-line babel/camelcase + lineNumber: isHead ? new_line : old_line, + section: isHead ? 'head' : 'origin', + richText: rich_text, + isSelected: false, + isUnselected: false, + }; +}; + +export const getOriginHeaderLine = (id) => { + return { + id, + richText: ORIGIN_HEADER_TEXT, + buttonTitle: ORIGIN_BUTTON_TITLE, + type: 'old', + section: 'origin', + isHeader: true, + isOrigin: true, + isSelected: false, + isUnselected: false, + }; +}; + +export const setInlineLine = (file) => { + const inlineLines = []; + + file.sections.forEach((section) => { + let currentLineType = 'new'; + const { conflict, lines, id } = section; + + if (conflict) { + inlineLines.push(getHeadHeaderLine(id)); + } + + lines.forEach((line) => { + const { type } = line; + + if ((type === 'new' || type === 'old') && currentLineType !== type) { + currentLineType = type; + inlineLines.push({ lineType: 'emptyLine', richText: '' }); + } + + const decoratedLine = decorateLineForInlineView(line, id, conflict); + inlineLines.push(decoratedLine); + }); + + if (conflict) { + inlineLines.push(getOriginHeaderLine(id)); + } + }); + + return inlineLines; +}; + +export const setParallelLine = (file) => { + const parallelLines = []; + let linesObj = { left: [], right: [] }; + + file.sections.forEach((section) => { + const { conflict, lines, id } = section; + + if (conflict) { + linesObj.left.push(getOriginHeaderLine(id)); + linesObj.right.push(getHeadHeaderLine(id)); + } + + lines.forEach((line) => { + const { type } = line; + + if (conflict) { + if (type === 'old') { + linesObj.left.push(getLineForParallelView(line, id, 'conflict')); + } else if (type === 'new') { + linesObj.right.push(getLineForParallelView(line, id, 'conflict', true)); + } + } else { + const lineType = type || 'context'; + + linesObj.left.push(getLineForParallelView(line, id, lineType)); + linesObj.right.push(getLineForParallelView(line, id, lineType, true)); + } + }); + + linesObj = checkLineLengths(linesObj); + }); + + for (let i = 0, len = linesObj.left.length; i < len; i += 1) { + parallelLines.push([linesObj.right[i], linesObj.left[i]]); + } + return parallelLines; +}; + +export const decorateFiles = (files) => { + return files.map((file) => { + const f = { ...file }; + f.content = ''; + f.resolutionData = {}; + f.promptDiscardConfirmation = false; + f.resolveMode = DEFAULT_RESOLVE_MODE; + f.filePath = getFilePath(file); + f.blobPath = f.blob_path; + + if (f.type === CONFLICT_TYPES.TEXT) { + f.showEditor = false; + f.loadEditor = false; + + f.inlineLines = setInlineLine(file); + f.parallelLines = setParallelLine(file); + } else if (f.type === CONFLICT_TYPES.TEXT_EDITOR) { + f.showEditor = true; + f.loadEditor = true; + } + return f; + }); +}; + +export const restoreFileLinesState = (file) => { + const inlineLines = file.inlineLines.map((line) => { + if (line.hasConflict || line.isHeader) { + return { ...line, isSelected: false, isUnselected: false }; + } + return { ...line }; + }); + + const parallelLines = file.parallelLines.map((lines) => { + const left = { ...lines[0] }; + const right = { ...lines[1] }; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (isLeftMatch || isRightMatch) { + left.isSelected = false; + left.isUnselected = false; + right.isSelected = false; + right.isUnselected = false; + } + return [left, right]; + }); + return { inlineLines, parallelLines }; +}; + +export const markLine = (line, selection) => { + const updated = { ...line }; + if (selection === 'head' && line.isHead) { + updated.isSelected = true; + updated.isUnselected = false; + } else if (selection === 'origin' && updated.isOrigin) { + updated.isSelected = true; + updated.isUnselected = false; + } else { + updated.isSelected = false; + updated.isUnselected = true; + } + return updated; +}; |