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'; ...@@ -2,7 +2,9 @@ const HEAD_HEADER_TEXT = 'HEAD//our changes';
const ORIGIN_HEADER_TEXT = 'origin//their changes'; const ORIGIN_HEADER_TEXT = 'origin//their changes';
const HEAD_BUTTON_TITLE = 'Use ours'; const HEAD_BUTTON_TITLE = 'Use ours';
const ORIGIN_BUTTON_TITLE = 'Use theirs'; 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 { class MergeConflictDataProvider {
...@@ -18,8 +20,7 @@ class MergeConflictDataProvider { ...@@ -18,8 +20,7 @@ class MergeConflictDataProvider {
diffViewType : diffViewType, diffViewType : diffViewType,
fixedLayout : fixedLayout, fixedLayout : fixedLayout,
isSubmitting : false, isSubmitting : false,
conflictsData : {}, conflictsData : {}
resolutionData : {}
} }
} }
...@@ -35,9 +36,9 @@ class MergeConflictDataProvider { ...@@ -35,9 +36,9 @@ class MergeConflictDataProvider {
data.shortCommitSha = data.commit_sha.slice(0, 7); data.shortCommitSha = data.commit_sha.slice(0, 7);
data.commitMessage = data.commit_message; data.commitMessage = data.commit_message;
this.decorateFiles(data);
this.setParallelLines(data); this.setParallelLines(data);
this.setInlineLines(data); this.setInlineLines(data);
this.updateResolutionsData(data);
} }
vueInstance.conflictsData = data; vueInstance.conflictsData = data;
...@@ -47,16 +48,12 @@ class MergeConflictDataProvider { ...@@ -47,16 +48,12 @@ class MergeConflictDataProvider {
vueInstance.conflictsData.conflictsText = conflictsText; vueInstance.conflictsData.conflictsText = conflictsText;
} }
decorateFiles(data) {
updateResolutionsData(data) { data.files.forEach((file) => {
const vi = this.vueInstance; file.content = '';
file.resolutionData = {};
data.files.forEach( (file) => { file.promptDiscardConfirmation = false;
file.sections.forEach( (section) => { file.resolveMode = DEFAULT_RESOLVE_MODE;
if (section.conflict) {
vi.$set(`resolutionData['${section.id}']`, false);
}
});
}); });
} }
...@@ -165,11 +162,14 @@ class MergeConflictDataProvider { ...@@ -165,11 +162,14 @@ class MergeConflictDataProvider {
} }
handleSelected(sectionId, selection) { handleSelected(file, sectionId, selection) {
const vi = this.vueInstance; 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) => { file.inlineLines.forEach( (line) => {
if (line.id === sectionId && (line.hasConflict || line.isHeader)) { if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
this.markLine(line, selection); this.markLine(line, selection);
...@@ -208,6 +208,48 @@ class MergeConflictDataProvider { ...@@ -208,6 +208,48 @@ class MergeConflictDataProvider {
.toggleClass('container-limited', !vi.isParallel && vi.fixedLayout); .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) { markLine(line, selection) {
if (selection === 'head' && line.isHead) { if (selection === 'head' && line.isHead) {
...@@ -226,31 +268,54 @@ class MergeConflictDataProvider { ...@@ -226,31 +268,54 @@ class MergeConflictDataProvider {
getConflictsCount() { getConflictsCount() {
return Object.keys(this.vueInstance.resolutionData).length; const files = this.vueInstance.conflictsData.files;
}
getResolvedCount() {
let count = 0; let count = 0;
const data = this.vueInstance.resolutionData;
for (const id in data) { files.forEach((file) => {
const resolution = data[id]; file.sections.forEach((section) => {
if (resolution) { if (section.conflict) {
count++; count++;
} }
} });
});
return count; return count;
} }
isReadyToCommit() { isReadyToCommit() {
const { conflictsData, isSubmitting } = this.vueInstance const vi = this.vueInstance;
const allResolved = this.getConflictsCount() === this.getResolvedCount(); const files = this.vueInstance.conflictsData.files;
const hasCommitMessage = $.trim(conflictsData.commitMessage).length; 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 { ...@@ -332,10 +397,33 @@ class MergeConflictDataProvider {
getCommitData() { getCommitData() {
return { let conflictsData = this.vueInstance.conflictsData;
commit_message: this.vueInstance.conflictsData.commitMessage, let commitData = {};
sections: this.vueInstance.resolutionData
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 { ...@@ -343,5 +431,4 @@ class MergeConflictDataProvider {
const { old_path, new_path } = file; const { old_path, new_path } = file;
return old_path === new_path ? new_path : `${old_path} → ${new_path}`; return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
} }
} }
//= require vue //= require vue
//= require ./merge_conflicts/components/diff_file_editor
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const EDIT_RESOLVE_MODE = 'edit';
class MergeConflictResolver { class MergeConflictResolver {
constructor() { constructor() {
this.dataProvider = new MergeConflictDataProvider() this.dataProvider = new MergeConflictDataProvider();
this.initVue() this.initVue();
} }
initVue() { initVue() {
const that = this; const that = this;
this.vue = new Vue({ this.vue = new Vue({
...@@ -17,15 +20,28 @@ class MergeConflictResolver { ...@@ -17,15 +20,28 @@ class MergeConflictResolver {
created : this.fetchData(), created : this.fetchData(),
computed : this.setComputedProperties(), computed : this.setComputedProperties(),
methods : { methods : {
handleSelected(sectionId, selection) { handleSelected(file, sectionId, selection) {
that.dataProvider.handleSelected(sectionId, selection); that.dataProvider.handleSelected(file, sectionId, selection);
}, },
handleViewTypeChange(newType) { handleViewTypeChange(newType) {
that.dataProvider.updateViewType(newType); that.dataProvider.updateViewType(newType);
}, },
commit() { commit() {
that.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 { ...@@ -36,7 +52,6 @@ class MergeConflictResolver {
return { return {
conflictsCount() { return dp.getConflictsCount() }, conflictsCount() { return dp.getConflictsCount() },
resolvedCount() { return dp.getResolvedCount() },
readyToCommit() { return dp.isReadyToCommit() }, readyToCommit() { return dp.isReadyToCommit() },
commitButtonText() { return dp.getCommitButtonText() } commitButtonText() { return dp.getCommitButtonText() }
} }
...@@ -69,7 +84,13 @@ class MergeConflictResolver { ...@@ -69,7 +84,13 @@ class MergeConflictResolver {
commit() { commit() {
this.vue.isSubmitting = true; this.vue.isSubmitting = true;
$.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData()) $.ajax({
url: $('#conflicts').data('resolveConflictsPath'),
data: JSON.stringify(this.dataProvider.getCommitData()),
contentType: "application/json",
dataType: 'json',
method: 'POST'
})
.done((data) => { .done((data) => {
window.location.href = data.redirect_to; window.location.href = data.redirect_to;
}) })
...@@ -79,4 +100,13 @@ class MergeConflictResolver { ...@@ -79,4 +100,13 @@ class MergeConflictResolver {
}); });
} }
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: ( ...@@ -235,4 +235,30 @@ $colors: (
.btn-success .fa-spinner { .btn-success .fa-spinner {
color: #fff; 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 @@ ...@@ -27,3 +27,6 @@
= render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings } = 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/inline_view", locals: { class_bindings: class_bindings }
= render partial: "projects/merge_requests/conflicts/submit_form" = 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 @@ ...@@ -3,12 +3,9 @@
.file-title .file-title
%i.fa.fa-fw{":class" => "file.iconClass"} %i.fa.fa-fw{":class" => "file.iconClass"}
%strong {{file.filePath}} %strong {{file.filePath}}
.file-actions = render partial: 'projects/merge_requests/conflicts/file_actions'
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
View file @{{conflictsData.shortCommitSha}}
.diff-content.diff-wrap-lines .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 %table
%tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"} %tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"}
%template{"v-if" => "!line.isHeader"} %template{"v-if" => "!line.isHeader"}
...@@ -24,5 +21,6 @@ ...@@ -24,5 +21,6 @@
%td.diff-line-num.header{":class" => class_bindings} %td.diff-line-num.header{":class" => class_bindings}
%td.line_content.header{":class" => class_bindings} %td.line_content.header{":class" => class_bindings}
%strong {{{line.richText}}} %strong {{{line.richText}}}
%button.btn{"@click" => "handleSelected(line.id, line.section)"} %button.btn{ "@click" => "handleSelected(file, line.id, line.section)" }
{{line.buttonTitle}} {{line.buttonTitle}}
= render partial: 'projects/merge_requests/conflicts/diff_file_editor', locals: { if_condition: "file.resolveMode === 'edit' && !isParallel" }
...@@ -3,12 +3,9 @@ ...@@ -3,12 +3,9 @@
.file-title .file-title
%i.fa.fa-fw{":class" => "file.iconClass"} %i.fa.fa-fw{":class" => "file.iconClass"}
%strong {{file.filePath}} %strong {{file.filePath}}
.file-actions = render partial: 'projects/merge_requests/conflicts/file_actions'
%a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
View file @{{conflictsData.shortCommitSha}}
.diff-content.diff-wrap-lines .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 %table
%tr.line_holder.parallel{"v-for" => "section in file.parallelLines"} %tr.line_holder.parallel{"v-for" => "section in file.parallelLines"}
%template{"v-for" => "line in section"} %template{"v-for" => "line in section"}
...@@ -17,7 +14,7 @@ ...@@ -17,7 +14,7 @@
%td.diff-line-num.header{":class" => class_bindings} %td.diff-line-num.header{":class" => class_bindings}
%td.line_content.header{":class" => class_bindings} %td.line_content.header{":class" => class_bindings}
%strong {{line.richText}} %strong {{line.richText}}
%button.btn{"@click" => "handleSelected(line.id, line.section)"} %button.btn{"@click" => "handleSelected(file, line.id, line.section)"}
{{line.buttonTitle}} {{line.buttonTitle}}
%template{"v-if" => "!line.isHeader"} %template{"v-if" => "!line.isHeader"}
...@@ -25,3 +22,4 @@ ...@@ -25,3 +22,4 @@
{{line.lineNumber}} {{line.lineNumber}}
%td.line_content.parallel{":class" => class_bindings} %td.line_content.parallel{":class" => class_bindings}
{{{line.richText}}} {{{line.richText}}}
= render partial: 'projects/merge_requests/conflicts/diff_file_editor', locals: { if_condition: "file.resolveMode === 'edit' && isParallel" }
.content-block.oneline-block.files-changed .content-block
%strong.resolved-count {{resolvedCount}}
of
%strong.total-count {{conflictsCount}}
conflicts have been resolved
.commit-message-container.form-group .commit-message-container.form-group
.max-width-marker .max-width-marker
%textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"} %textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"}
{{{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}} %span {{commitButtonText}}
= link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel" = 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