Implement editor to manually resolve merge conflicts
This commit is contained in:
parent
6af52d7d23
commit
26f658decd
11 changed files with 300 additions and 73 deletions
|
@ -2,7 +2,9 @@ const HEAD_HEADER_TEXT = 'HEAD//our changes';
|
|||
const ORIGIN_HEADER_TEXT = 'origin//their changes';
|
||||
const HEAD_BUTTON_TITLE = 'Use ours';
|
||||
const ORIGIN_BUTTON_TITLE = 'Use theirs';
|
||||
|
||||
const INTERACTIVE_RESOLVE_MODE = 'interactive';
|
||||
const EDIT_RESOLVE_MODE = 'edit';
|
||||
const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
|
||||
|
||||
class MergeConflictDataProvider {
|
||||
|
||||
|
@ -18,8 +20,7 @@ class MergeConflictDataProvider {
|
|||
diffViewType : diffViewType,
|
||||
fixedLayout : fixedLayout,
|
||||
isSubmitting : false,
|
||||
conflictsData : {},
|
||||
resolutionData : {}
|
||||
conflictsData : {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,9 +36,9 @@ class MergeConflictDataProvider {
|
|||
data.shortCommitSha = data.commit_sha.slice(0, 7);
|
||||
data.commitMessage = data.commit_message;
|
||||
|
||||
this.decorateFiles(data);
|
||||
this.setParallelLines(data);
|
||||
this.setInlineLines(data);
|
||||
this.updateResolutionsData(data);
|
||||
}
|
||||
|
||||
vueInstance.conflictsData = data;
|
||||
|
@ -47,16 +48,12 @@ class MergeConflictDataProvider {
|
|||
vueInstance.conflictsData.conflictsText = conflictsText;
|
||||
}
|
||||
|
||||
|
||||
updateResolutionsData(data) {
|
||||
const vi = this.vueInstance;
|
||||
|
||||
data.files.forEach( (file) => {
|
||||
file.sections.forEach( (section) => {
|
||||
if (section.conflict) {
|
||||
vi.$set(`resolutionData['${section.id}']`, false);
|
||||
}
|
||||
});
|
||||
decorateFiles(data) {
|
||||
data.files.forEach((file) => {
|
||||
file.content = '';
|
||||
file.resolutionData = {};
|
||||
file.promptDiscardConfirmation = false;
|
||||
file.resolveMode = DEFAULT_RESOLVE_MODE;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -165,11 +162,14 @@ class MergeConflictDataProvider {
|
|||
}
|
||||
|
||||
|
||||
handleSelected(sectionId, selection) {
|
||||
handleSelected(file, sectionId, selection) {
|
||||
const vi = this.vueInstance;
|
||||
let files = vi.conflictsData.files;
|
||||
|
||||
vi.resolutionData[sectionId] = selection;
|
||||
vi.conflictsData.files.forEach( (file) => {
|
||||
vi.$set(`conflictsData.files[${files.indexOf(file)}].resolutionData['${sectionId}']`, selection);
|
||||
|
||||
|
||||
files.forEach( (file) => {
|
||||
file.inlineLines.forEach( (line) => {
|
||||
if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
|
||||
this.markLine(line, selection);
|
||||
|
@ -208,6 +208,48 @@ class MergeConflictDataProvider {
|
|||
.toggleClass('container-limited', !vi.isParallel && vi.fixedLayout);
|
||||
}
|
||||
|
||||
setFileResolveMode(file, mode) {
|
||||
const vi = this.vueInstance;
|
||||
|
||||
// Restore Interactive mode when switching to Edit mode
|
||||
if (mode === EDIT_RESOLVE_MODE) {
|
||||
file.resolutionData = {};
|
||||
|
||||
this.restoreFileLinesState(file);
|
||||
}
|
||||
|
||||
file.resolveMode = mode;
|
||||
}
|
||||
|
||||
|
||||
restoreFileLinesState(file) {
|
||||
file.inlineLines.forEach((line) => {
|
||||
if (line.hasConflict || line.isHeader) {
|
||||
line.isSelected = false;
|
||||
line.isUnselected = false;
|
||||
}
|
||||
});
|
||||
|
||||
file.parallelLines.forEach((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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setPromptConfirmationState(file, state) {
|
||||
file.promptDiscardConfirmation = state;
|
||||
}
|
||||
|
||||
|
||||
markLine(line, selection) {
|
||||
if (selection === 'head' && line.isHead) {
|
||||
|
@ -226,31 +268,54 @@ class MergeConflictDataProvider {
|
|||
|
||||
|
||||
getConflictsCount() {
|
||||
return Object.keys(this.vueInstance.resolutionData).length;
|
||||
}
|
||||
const files = this.vueInstance.conflictsData.files;
|
||||
let count = 0;
|
||||
|
||||
|
||||
getResolvedCount() {
|
||||
let count = 0;
|
||||
const data = this.vueInstance.resolutionData;
|
||||
|
||||
for (const id in data) {
|
||||
const resolution = data[id];
|
||||
if (resolution) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
files.forEach((file) => {
|
||||
file.sections.forEach((section) => {
|
||||
if (section.conflict) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
isReadyToCommit() {
|
||||
const { conflictsData, isSubmitting } = this.vueInstance
|
||||
const allResolved = this.getConflictsCount() === this.getResolvedCount();
|
||||
const hasCommitMessage = $.trim(conflictsData.commitMessage).length;
|
||||
const vi = this.vueInstance;
|
||||
const files = this.vueInstance.conflictsData.files;
|
||||
const hasCommitMessage = $.trim(this.vueInstance.conflictsData.commitMessage).length;
|
||||
let unresolved = 0;
|
||||
|
||||
return !isSubmitting && hasCommitMessage && allResolved;
|
||||
for (let i = 0, l = files.length; i < l; i++) {
|
||||
let file = files[i];
|
||||
|
||||
if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
|
||||
let numberConflicts = 0;
|
||||
let resolvedConflicts = Object.keys(file.resolutionData).length
|
||||
|
||||
for (let j = 0, k = file.sections.length; j < k; j++) {
|
||||
if (file.sections[j].conflict) {
|
||||
numberConflicts++;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedConflicts !== numberConflicts) {
|
||||
unresolved++;
|
||||
}
|
||||
} 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++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !vi.isSubmitting && hasCommitMessage && !unresolved;
|
||||
}
|
||||
|
||||
|
||||
|
@ -332,10 +397,33 @@ class MergeConflictDataProvider {
|
|||
|
||||
|
||||
getCommitData() {
|
||||
return {
|
||||
commit_message: this.vueInstance.conflictsData.commitMessage,
|
||||
sections: this.vueInstance.resolutionData
|
||||
}
|
||||
let conflictsData = this.vueInstance.conflictsData;
|
||||
let commitData = {};
|
||||
|
||||
commitData = {
|
||||
commitMessage: conflictsData.commitMessage,
|
||||
files: []
|
||||
};
|
||||
|
||||
conflictsData.files.forEach((file) => {
|
||||
let addFile;
|
||||
|
||||
addFile = {
|
||||
old_path: file.old_path,
|
||||
new_path: file.new_path
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
commitData.files.push(addFile);
|
||||
});
|
||||
|
||||
return commitData;
|
||||
}
|
||||
|
||||
|
||||
|
@ -343,5 +431,4 @@ class MergeConflictDataProvider {
|
|||
const { old_path, new_path } = file;
|
||||
return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
//= require vue
|
||||
//= require ./merge_conflicts/components/diff_file_editor
|
||||
|
||||
const INTERACTIVE_RESOLVE_MODE = 'interactive';
|
||||
const EDIT_RESOLVE_MODE = 'edit';
|
||||
|
||||
class MergeConflictResolver {
|
||||
|
||||
constructor() {
|
||||
this.dataProvider = new MergeConflictDataProvider()
|
||||
this.initVue()
|
||||
this.dataProvider = new MergeConflictDataProvider();
|
||||
this.initVue();
|
||||
}
|
||||
|
||||
|
||||
initVue() {
|
||||
const that = this;
|
||||
this.vue = new Vue({
|
||||
|
@ -17,15 +20,28 @@ class MergeConflictResolver {
|
|||
created : this.fetchData(),
|
||||
computed : this.setComputedProperties(),
|
||||
methods : {
|
||||
handleSelected(sectionId, selection) {
|
||||
that.dataProvider.handleSelected(sectionId, selection);
|
||||
handleSelected(file, sectionId, selection) {
|
||||
that.dataProvider.handleSelected(file, sectionId, selection);
|
||||
},
|
||||
handleViewTypeChange(newType) {
|
||||
that.dataProvider.updateViewType(newType);
|
||||
},
|
||||
commit() {
|
||||
that.commit();
|
||||
}
|
||||
},
|
||||
onClickResolveModeButton(file, mode) {
|
||||
that.toggleResolveMode(file, mode);
|
||||
},
|
||||
acceptDiscardConfirmation(file) {
|
||||
that.dataProvider.setPromptConfirmationState(file, false);
|
||||
that.dataProvider.setFileResolveMode(file, INTERACTIVE_RESOLVE_MODE);
|
||||
},
|
||||
cancelDiscardConfirmation(file) {
|
||||
that.dataProvider.setPromptConfirmationState(file, false);
|
||||
},
|
||||
},
|
||||
components: {
|
||||
'diff-file-editor': window.gl.diffFileEditor
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -36,7 +52,6 @@ class MergeConflictResolver {
|
|||
|
||||
return {
|
||||
conflictsCount() { return dp.getConflictsCount() },
|
||||
resolvedCount() { return dp.getResolvedCount() },
|
||||
readyToCommit() { return dp.isReadyToCommit() },
|
||||
commitButtonText() { return dp.getCommitButtonText() }
|
||||
}
|
||||
|
@ -69,14 +84,29 @@ class MergeConflictResolver {
|
|||
commit() {
|
||||
this.vue.isSubmitting = true;
|
||||
|
||||
$.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData())
|
||||
.done((data) => {
|
||||
window.location.href = data.redirect_to;
|
||||
})
|
||||
.error(() => {
|
||||
this.vue.isSubmitting = false;
|
||||
new Flash('Something went wrong!');
|
||||
});
|
||||
$.ajax({
|
||||
url: $('#conflicts').data('resolveConflictsPath'),
|
||||
data: JSON.stringify(this.dataProvider.getCommitData()),
|
||||
contentType: "application/json",
|
||||
dataType: 'json',
|
||||
method: 'POST'
|
||||
})
|
||||
.done((data) => {
|
||||
window.location.href = data.redirect_to;
|
||||
})
|
||||
.error(() => {
|
||||
this.vue.isSubmitting = false;
|
||||
new Flash('Something went wrong!');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
toggleResolveMode(file, mode) {
|
||||
if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) {
|
||||
this.dataProvider.setPromptConfirmationState(file, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataProvider.setFileResolveMode(file, mode);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
((global) => {
|
||||
global.diffFileEditor = Vue.extend({
|
||||
props: ['file', 'loadFile'],
|
||||
template: '#diff-file-editor',
|
||||
data() {
|
||||
return {
|
||||
originalState: '',
|
||||
saved: false,
|
||||
loading: false,
|
||||
fileLoaded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
classObject() {
|
||||
return {
|
||||
'load-file': this.loadFile,
|
||||
'saved': this.saved,
|
||||
'is-loading': this.loading
|
||||
};
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
loadFile(val) {
|
||||
const self = this;
|
||||
|
||||
if (!val || this.fileLoaded || this.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
$.get(this.file.content_path)
|
||||
.done((file) => {
|
||||
$(self.$el).find('textarea').val(file.content);
|
||||
|
||||
self.originalState = file.content;
|
||||
self.fileLoaded = true;
|
||||
self.saveDiffResolution();
|
||||
})
|
||||
.fail(() => {
|
||||
console.log('error');
|
||||
})
|
||||
.always(() => {
|
||||
self.loading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveDiffResolution() {
|
||||
this.saved = true;
|
||||
|
||||
// This probably be better placed in the data provider
|
||||
this.file.content = this.$el.querySelector('textarea').value;
|
||||
this.file.resolveEditChanged = this.file.content !== this.originalState;
|
||||
this.file.promptDiscardConfirmation = false;
|
||||
},
|
||||
onInput() {
|
||||
this.saveDiffResolution();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -235,4 +235,30 @@ $colors: (
|
|||
.btn-success .fa-spinner {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.editor-wrap {
|
||||
&.is-loading {
|
||||
.editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&.saved {
|
||||
.editor {
|
||||
border-top: solid 1px green;
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
border-top: solid 1px yellow;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,3 +27,6 @@
|
|||
= render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings }
|
||||
= render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings }
|
||||
= render partial: "projects/merge_requests/conflicts/submit_form"
|
||||
|
||||
-# Components
|
||||
= render partial: 'projects/merge_requests/conflicts/components/diff_file_editor'
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
- if_condition = local_assigns.fetch(:if_condition, '')
|
||||
|
||||
.diff-editor-wrap{ "v-show" => if_condition }
|
||||
.discard-changes-alert-wrap{ "v-if" => "file.promptDiscardConfirmation" }
|
||||
%p
|
||||
Are you sure to discard your changes?
|
||||
%button.btn.btn-sm.btn-close{ "@click" => "acceptDiscardConfirmation(file)" } Discard changes
|
||||
%button.btn.btn-sm{ "@click" => "cancelDiscardConfirmation(file)" } Cancel
|
||||
%diff-file-editor{":file" => "file", ":load-file" => if_condition }
|
|
@ -0,0 +1,12 @@
|
|||
.file-actions
|
||||
.btn-group
|
||||
%button.btn{ ":class" => "{ 'active': file.resolveMode == 'interactive' }",
|
||||
'@click' => "onClickResolveModeButton(file, 'interactive')",
|
||||
type: 'button' }
|
||||
Interactive mode
|
||||
%button.btn{ ':class' => "{ 'active': file.resolveMode == 'edit' }",
|
||||
'@click' => "onClickResolveModeButton(file, 'edit')",
|
||||
type: 'button' }
|
||||
Edit inline
|
||||
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
|
||||
View file @{{conflictsData.shortCommitSha}}
|
|
@ -3,12 +3,9 @@
|
|||
.file-title
|
||||
%i.fa.fa-fw{":class" => "file.iconClass"}
|
||||
%strong {{file.filePath}}
|
||||
.file-actions
|
||||
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
|
||||
View file @{{conflictsData.shortCommitSha}}
|
||||
|
||||
= render partial: 'projects/merge_requests/conflicts/file_actions'
|
||||
.diff-content.diff-wrap-lines
|
||||
.diff-wrap-lines.code.file-content.js-syntax-highlight
|
||||
.diff-wrap-lines.code.file-content.js-syntax-highlight{ 'v-show' => "file.resolveMode === 'interactive'" }
|
||||
%table
|
||||
%tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"}
|
||||
%template{"v-if" => "!line.isHeader"}
|
||||
|
@ -24,5 +21,6 @@
|
|||
%td.diff-line-num.header{":class" => class_bindings}
|
||||
%td.line_content.header{":class" => class_bindings}
|
||||
%strong {{{line.richText}}}
|
||||
%button.btn{"@click" => "handleSelected(line.id, line.section)"}
|
||||
%button.btn{ "@click" => "handleSelected(file, line.id, line.section)" }
|
||||
{{line.buttonTitle}}
|
||||
= render partial: 'projects/merge_requests/conflicts/diff_file_editor', locals: { if_condition: "file.resolveMode === 'edit' && !isParallel" }
|
||||
|
|
|
@ -3,12 +3,9 @@
|
|||
.file-title
|
||||
%i.fa.fa-fw{":class" => "file.iconClass"}
|
||||
%strong {{file.filePath}}
|
||||
.file-actions
|
||||
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
|
||||
View file @{{conflictsData.shortCommitSha}}
|
||||
|
||||
= render partial: 'projects/merge_requests/conflicts/file_actions'
|
||||
.diff-content.diff-wrap-lines
|
||||
.diff-wrap-lines.code.file-content.js-syntax-highlight
|
||||
.diff-wrap-lines.code.file-content.js-syntax-highlight{ 'v-show' => "file.resolveMode === 'interactive'" }
|
||||
%table
|
||||
%tr.line_holder.parallel{"v-for" => "section in file.parallelLines"}
|
||||
%template{"v-for" => "line in section"}
|
||||
|
@ -17,7 +14,7 @@
|
|||
%td.diff-line-num.header{":class" => class_bindings}
|
||||
%td.line_content.header{":class" => class_bindings}
|
||||
%strong {{line.richText}}
|
||||
%button.btn{"@click" => "handleSelected(line.id, line.section)"}
|
||||
%button.btn{"@click" => "handleSelected(file, line.id, line.section)"}
|
||||
{{line.buttonTitle}}
|
||||
|
||||
%template{"v-if" => "!line.isHeader"}
|
||||
|
@ -25,3 +22,4 @@
|
|||
{{line.lineNumber}}
|
||||
%td.line_content.parallel{":class" => class_bindings}
|
||||
{{{line.richText}}}
|
||||
= render partial: 'projects/merge_requests/conflicts/diff_file_editor', locals: { if_condition: "file.resolveMode === 'edit' && isParallel" }
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
.content-block.oneline-block.files-changed
|
||||
%strong.resolved-count {{resolvedCount}}
|
||||
of
|
||||
%strong.total-count {{conflictsCount}}
|
||||
conflicts have been resolved
|
||||
|
||||
.content-block
|
||||
.commit-message-container.form-group
|
||||
.max-width-marker
|
||||
%textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"}
|
||||
{{{conflictsData.commitMessage}}}
|
||||
|
||||
%button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"}
|
||||
%button{type: "button", class: "btn btn-success js-submit-button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
|
||||
%span {{commitButtonText}}
|
||||
|
||||
= link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
%template{ id: "diff-file-editor" }
|
||||
%div
|
||||
.editor-wrap{ ":class" => "classObject" }
|
||||
%p.loading-text Loading...
|
||||
.editor
|
||||
%textarea{ "@input" => "onInput", cols: '80', rows: '20' }
|
Loading…
Reference in a new issue