Commit 26f658de authored by Alfredo Sumaran's avatar Alfredo Sumaran

Implement editor to manually resolve merge conflicts

parent 6af52d7d
......@@ -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.$set(`conflictsData.files[${files.indexOf(file)}].resolutionData['${sectionId}']`, selection);
vi.resolutionData[sectionId] = selection;
vi.conflictsData.files.forEach( (file) => {
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;
}
getResolvedCount() {
let count = 0;
const data = this.vueInstance.resolutionData;
const files = this.vueInstance.conflictsData.files;
let count = 0;
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;
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 !isSubmitting && hasCommitMessage && allResolved;
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}`;
}
}
//= 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);
}
}
((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'
- 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 }
.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" }
.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"
%template{ id: "diff-file-editor" }
%div
.editor-wrap{ ":class" => "classObject" }
%p.loading-text Loading...
.editor
%textarea{ "@input" => "onInput", cols: '80', rows: '20' }
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment