Refactor JS code
- Use a store base object to manage application state. - Add a service to handle ajax requests. - Load code only when needed
This commit is contained in:
parent
e84f959ae4
commit
a8ac9089af
|
@ -97,9 +97,6 @@
|
|||
new ZenMode();
|
||||
new MergedButtons();
|
||||
break;
|
||||
case "projects:merge_requests:conflicts":
|
||||
window.mcui = new MergeConflictResolver()
|
||||
break;
|
||||
case 'projects:merge_requests:index':
|
||||
shortcut_handler = new ShortcutsNavigation();
|
||||
Issuable.init();
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
((global) => {
|
||||
global.diffFileEditor = Vue.extend({
|
||||
|
||||
global.mergeConflicts = global.mergeConflicts || {};
|
||||
|
||||
global.mergeConflicts.diffFileEditor = Vue.extend({
|
||||
props: ['file', 'loadFile'],
|
||||
template: '#diff-file-editor',
|
||||
data() {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
((global) => {
|
||||
global.mergeConflicts = global.mergeConflicts || {};
|
||||
|
||||
class mergeConflictsService {
|
||||
constructor(options) {
|
||||
this.conflictsPath = options.conflictsPath;
|
||||
this.resolveConflictsPath = options.resolveConflictsPath;
|
||||
}
|
||||
|
||||
fetchConflictsData() {
|
||||
return $.ajax({
|
||||
dataType: 'json',
|
||||
url: this.conflictsPath
|
||||
});
|
||||
}
|
||||
|
||||
submitResolveConflicts(data) {
|
||||
return $.ajax({
|
||||
url: this.resolveConflictsPath,
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
global.mergeConflicts.mergeConflictsService = mergeConflictsService;
|
||||
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -0,0 +1,419 @@
|
|||
((global) => {
|
||||
global.mergeConflicts = global.mergeConflicts || {};
|
||||
|
||||
const diffViewType = $.cookie('diff_view');
|
||||
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;
|
||||
const VIEW_TYPES = {
|
||||
INLINE: 'inline',
|
||||
PARALLEL: 'parallel'
|
||||
};
|
||||
|
||||
global.mergeConflicts.mergeConflictsStore = {
|
||||
state: {
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
isSubmitting: false,
|
||||
isParallel: diffViewType === VIEW_TYPES.PARALLEL,
|
||||
diffViewType: diffViewType,
|
||||
conflictsData: {}
|
||||
},
|
||||
|
||||
setConflictsData(data) {
|
||||
this.decorateFiles(data.files);
|
||||
this.setInlineLines(data.files);
|
||||
this.setParallelLines(data.files);
|
||||
|
||||
this.state.conflictsData = {
|
||||
files: data.files,
|
||||
commitMessage: data.commit_message,
|
||||
sourceBranch: data.source_branch,
|
||||
targetBranch: data.target_branch,
|
||||
commitMessage: data.commit_message,
|
||||
shortCommitSha: data.commit_sha.slice(0, 7),
|
||||
};
|
||||
},
|
||||
|
||||
decorateFiles(files) {
|
||||
files.forEach((file) => {
|
||||
file.content = '';
|
||||
file.resolutionData = {};
|
||||
file.promptDiscardConfirmation = false;
|
||||
file.resolveMode = DEFAULT_RESOLVE_MODE;
|
||||
});
|
||||
},
|
||||
|
||||
setInlineLines(files) {
|
||||
files.forEach((file) => {
|
||||
file.iconClass = `fa-${file.blob_icon}`;
|
||||
file.blobPath = file.blob_path;
|
||||
file.filePath = this.getFilePath(file);
|
||||
file.inlineLines = [];
|
||||
|
||||
file.sections.forEach((section) => {
|
||||
let currentLineType = 'new';
|
||||
const { conflict, lines, id } = section;
|
||||
|
||||
if (conflict) {
|
||||
file.inlineLines.push(this.getHeadHeaderLine(id));
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
const { type } = line;
|
||||
|
||||
if ((type === 'new' || type === 'old') && currentLineType !== type) {
|
||||
currentLineType = type;
|
||||
file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
|
||||
}
|
||||
|
||||
this.decorateLineForInlineView(line, id, conflict);
|
||||
file.inlineLines.push(line);
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
file.inlineLines.push(this.getOriginHeaderLine(id));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setParallelLines(files) {
|
||||
files.forEach((file) => {
|
||||
file.filePath = this.getFilePath(file);
|
||||
file.iconClass = `fa-${file.blob_icon}`;
|
||||
file.blobPath = file.blob_path;
|
||||
file.parallelLines = [];
|
||||
const linesObj = { left: [], right: [] };
|
||||
|
||||
file.sections.forEach((section) => {
|
||||
const { conflict, lines, id } = section;
|
||||
|
||||
if (conflict) {
|
||||
linesObj.left.push(this.getOriginHeaderLine(id));
|
||||
linesObj.right.push(this.getHeadHeaderLine(id));
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
const { type } = line;
|
||||
|
||||
if (conflict) {
|
||||
if (type === 'old') {
|
||||
linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
|
||||
}
|
||||
else if (type === 'new') {
|
||||
linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
|
||||
}
|
||||
}
|
||||
else {
|
||||
const lineType = type || 'context';
|
||||
|
||||
linesObj.left.push (this.getLineForParallelView(line, id, lineType));
|
||||
linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
|
||||
}
|
||||
});
|
||||
|
||||
this.checkLineLengths(linesObj);
|
||||
});
|
||||
|
||||
for (let i = 0, len = linesObj.left.length; i < len; i++) {
|
||||
file.parallelLines.push([
|
||||
linesObj.right[i],
|
||||
linesObj.left[i]
|
||||
]);
|
||||
}
|
||||
|
||||
return file;
|
||||
});
|
||||
},
|
||||
|
||||
setLoadingState(state) {
|
||||
this.state.isLoading = state;
|
||||
},
|
||||
|
||||
setErrorState(state) {
|
||||
this.state.hasError = state;
|
||||
},
|
||||
|
||||
setFailedRequest(message) {
|
||||
console.log('setFailedRequest');
|
||||
this.state.hasError = true;
|
||||
this.state.conflictsData.errorMessage = message;
|
||||
},
|
||||
|
||||
getConflictsCount() {
|
||||
if (!this.state.conflictsData.files) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const files = this.state.conflictsData.files;
|
||||
let count = 0;
|
||||
|
||||
files.forEach((file) => {
|
||||
file.sections.forEach((section) => {
|
||||
if (section.conflict) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return count;
|
||||
},
|
||||
|
||||
getConflictsCountText() {
|
||||
const count = this.getConflictsCount();
|
||||
const text = count ? 'conflicts' : 'conflict';
|
||||
|
||||
return `${count} ${text}`;
|
||||
},
|
||||
|
||||
setViewType(viewType) {
|
||||
this.state.diffView = viewType;
|
||||
this.state.isParallel = viewType === VIEW_TYPES.PARALLEL;
|
||||
|
||||
$.cookie('diff_view', viewType, {
|
||||
path: gon.relative_url_root || '/'
|
||||
});
|
||||
},
|
||||
|
||||
getHeadHeaderLine(id) {
|
||||
return {
|
||||
id: id,
|
||||
richText: HEAD_HEADER_TEXT,
|
||||
buttonTitle: HEAD_BUTTON_TITLE,
|
||||
type: 'new',
|
||||
section: 'head',
|
||||
isHeader: true,
|
||||
isHead: true,
|
||||
isSelected: false,
|
||||
isUnselected: false
|
||||
}
|
||||
},
|
||||
|
||||
decorateLineForInlineView(line, id, conflict) {
|
||||
const { type } = line;
|
||||
line.id = id;
|
||||
line.hasConflict = conflict;
|
||||
line.isHead = type === 'new';
|
||||
line.isOrigin = type === 'old';
|
||||
line.hasMatch = type === 'match';
|
||||
line.richText = line.rich_text;
|
||||
line.isSelected = false;
|
||||
line.isUnselected = false;
|
||||
},
|
||||
|
||||
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',
|
||||
lineNumber: isHead ? new_line : old_line,
|
||||
section: isHead ? 'head' : 'origin',
|
||||
richText: rich_text,
|
||||
isSelected: false,
|
||||
isUnselected: false
|
||||
}
|
||||
},
|
||||
|
||||
getOriginHeaderLine(id) {
|
||||
return {
|
||||
id: id,
|
||||
richText: ORIGIN_HEADER_TEXT,
|
||||
buttonTitle: ORIGIN_BUTTON_TITLE,
|
||||
type: 'old',
|
||||
section: 'origin',
|
||||
isHeader: true,
|
||||
isOrigin: true,
|
||||
isSelected: false,
|
||||
isUnselected: false
|
||||
}
|
||||
},
|
||||
|
||||
getFilePath(file) {
|
||||
const { old_path, new_path } = file;
|
||||
return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
|
||||
},
|
||||
|
||||
checkLineLengths(linesObj) {
|
||||
let { left, right } = linesObj;
|
||||
|
||||
if (left.length !== right.length) {
|
||||
if (left.length > right.length) {
|
||||
const diff = left.length - right.length;
|
||||
for (let i = 0; i < diff; i++) {
|
||||
right.push({ lineType: 'emptyLine', richText: '' });
|
||||
}
|
||||
}
|
||||
else {
|
||||
const diff = right.length - left.length;
|
||||
for (let i = 0; i < diff; i++) {
|
||||
left.push({ lineType: 'emptyLine', richText: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setPromptConfirmationState(file, state) {
|
||||
file.promptDiscardConfirmation = state;
|
||||
},
|
||||
|
||||
setFileResolveMode(file, mode) {
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
isReadyToCommit() {
|
||||
const files = this.state.conflictsData.files;
|
||||
const hasCommitMessage = $.trim(this.state.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 !this.state.isSubmitting && hasCommitMessage && !unresolved;
|
||||
},
|
||||
|
||||
getCommitButtonText() {
|
||||
const initial = 'Commit conflict resolution';
|
||||
const inProgress = 'Committing...';
|
||||
|
||||
return this.state ? this.state.isSubmitting ? inProgress : initial : initial;
|
||||
},
|
||||
|
||||
getCommitData() {
|
||||
let commitData = {};
|
||||
|
||||
commitData = {
|
||||
commit_message: this.state.conflictsData.commitMessage,
|
||||
files: []
|
||||
};
|
||||
|
||||
this.state.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;
|
||||
},
|
||||
|
||||
handleSelected(file, sectionId, selection) {
|
||||
Vue.set(file.resolutionData, sectionId, selection);
|
||||
|
||||
this.state.conflictsData.files.forEach((file) => {
|
||||
file.inlineLines.forEach((line) => {
|
||||
if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
|
||||
this.markLine(line, selection);
|
||||
}
|
||||
});
|
||||
|
||||
file.parallelLines.forEach((lines) => {
|
||||
const left = lines[0];
|
||||
const right = lines[1];
|
||||
const hasSameId = right.id === sectionId || left.id === sectionId;
|
||||
const isLeftMatch = left.hasConflict || left.isHeader;
|
||||
const isRightMatch = right.hasConflict || right.isHeader;
|
||||
|
||||
if (hasSameId && (isLeftMatch || isRightMatch)) {
|
||||
this.markLine(left, selection);
|
||||
this.markLine(right, selection);
|
||||
}
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
markLine(line, selection) {
|
||||
if (selection === 'head' && line.isHead) {
|
||||
line.isSelected = true;
|
||||
line.isUnselected = false;
|
||||
}
|
||||
else if (selection === 'origin' && line.isOrigin) {
|
||||
line.isSelected = true;
|
||||
line.isUnselected = false;
|
||||
}
|
||||
else {
|
||||
line.isSelected = false;
|
||||
line.isUnselected = true;
|
||||
}
|
||||
},
|
||||
|
||||
setSubmitState(state) {
|
||||
this.state.isSubmitting = state;
|
||||
}
|
||||
};
|
||||
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -0,0 +1,84 @@
|
|||
//= require vue
|
||||
//= require ./merge_conflict_store
|
||||
//= require ./merge_conflict_service
|
||||
//= require ./components/diff_file_editor
|
||||
|
||||
$(() => {
|
||||
const INTERACTIVE_RESOLVE_MODE = 'interactive';
|
||||
const $conflicts = $(document.getElementById('conflicts'));
|
||||
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
|
||||
const mergeConflictsService = new gl.mergeConflicts.mergeConflictsService({
|
||||
conflictsPath: $conflicts.data('conflictsPath'),
|
||||
resolveConflictsPath: $conflicts.data('resolveConflictsPath')
|
||||
});
|
||||
|
||||
gl.MergeConflictsResolverApp = new Vue({
|
||||
el: '#conflicts',
|
||||
data: mergeConflictsStore.state,
|
||||
components: {
|
||||
'diff-file-editor': gl.mergeConflicts.diffFileEditor
|
||||
},
|
||||
computed: {
|
||||
conflictsCountText() { return mergeConflictsStore.getConflictsCountText() },
|
||||
readyToCommit() { return mergeConflictsStore.isReadyToCommit() },
|
||||
commitButtonText() { return mergeConflictsStore.getCommitButtonText() }
|
||||
},
|
||||
created() {
|
||||
mergeConflictsService
|
||||
.fetchConflictsData()
|
||||
.done((data) => {
|
||||
if (data.type === 'error') {
|
||||
mergeConflictsStore.setFailedRequest(data.message);
|
||||
} else {
|
||||
mergeConflictsStore.setConflictsData(data);
|
||||
}
|
||||
})
|
||||
.error(() => {
|
||||
mergeConflictsStore.setFailedRequest();
|
||||
})
|
||||
.always(() => {
|
||||
mergeConflictsStore.setLoadingState(false);
|
||||
|
||||
this.$nextTick(() => {
|
||||
$conflicts.find('.js-syntax-highlight').syntaxHighlight();
|
||||
});
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
handleSelected(file, sectionId, selection) {
|
||||
mergeConflictsStore.handleSelected(file, sectionId, selection);
|
||||
},
|
||||
handleViewTypeChange(viewType) {
|
||||
mergeConflictsStore.setViewType(viewType);
|
||||
},
|
||||
onClickResolveModeButton(file, mode) {
|
||||
if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) {
|
||||
mergeConflictsStore.setPromptConfirmationState(file, true);
|
||||
return;
|
||||
}
|
||||
|
||||
mergeConflictsStore.setFileResolveMode(file, mode);
|
||||
},
|
||||
acceptDiscardConfirmation(file) {
|
||||
mergeConflictsStore.setPromptConfirmationState(file, false);
|
||||
mergeConflictsStore.setFileResolveMode(file, INTERACTIVE_RESOLVE_MODE);
|
||||
},
|
||||
cancelDiscardConfirmation(file) {
|
||||
mergeConflictsStore.setPromptConfirmationState(file, false);
|
||||
},
|
||||
commit() {
|
||||
mergeConflictsStore.setSubmitState(true);
|
||||
|
||||
mergeConflictsService
|
||||
.submitResolveConflicts(mergeConflictsStore.getCommitData())
|
||||
.done((data) => {
|
||||
window.location.href = data.redirect_to;
|
||||
})
|
||||
.error(() => {
|
||||
mergeConflictsStore.setSubmitState(false);
|
||||
new Flash('Failed to save merge conflicts resolutions. Please try again!');
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
|
||||
- content_for :page_specific_javascripts do
|
||||
= page_specific_javascript_tag('merge_conflicts/merge_conflicts_bundle.js')
|
||||
= page_specific_javascript_tag('lib/ace.js')
|
||||
= render "projects/merge_requests/show/mr_title"
|
||||
|
||||
|
@ -26,8 +27,8 @@
|
|||
= render partial: "projects/merge_requests/conflicts/commit_stats"
|
||||
|
||||
.files-wrapper{"v-if" => "!isLoading && !hasError"}
|
||||
= 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/parallel_view", locals: { class_bindings: class_bindings }
|
||||
= render partial: "projects/merge_requests/conflicts/submit_form"
|
||||
|
||||
-# Components
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
.js-toggle-container
|
||||
.commit-stat-summary
|
||||
Showing
|
||||
%strong.cred {{conflictsCount}} {{conflictsData.conflictsText}}
|
||||
%strong.cred {{conflictsCountText}}
|
||||
between
|
||||
%strong {{conflictsData.source_branch}}
|
||||
%strong {{conflictsData.sourceBranch}}
|
||||
and
|
||||
%strong {{conflictsData.target_branch}}
|
||||
%strong {{conflictsData.targetBranch}}
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
.commit-message-container
|
||||
.max-width-marker
|
||||
%textarea.form-control.js-commit-message#commit-message{ "v-model" => "conflictsData.commitMessage", "rows" => "5" }
|
||||
{{{conflictsData.commitMessage}}}
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
.row
|
||||
|
|
|
@ -89,6 +89,7 @@ module Gitlab
|
|||
config.assets.precompile << "profile/profile_bundle.js"
|
||||
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
|
||||
config.assets.precompile << "boards/boards_bundle.js"
|
||||
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
|
||||
config.assets.precompile << "boards/test_utils/simulate_drag.js"
|
||||
config.assets.precompile << "blob_edit/blob_edit_bundle.js"
|
||||
config.assets.precompile << "snippet/snippet_bundle.js"
|
||||
|
|
|
@ -42,13 +42,13 @@ feature 'Merge request conflict resolution', js: true, feature: true do
|
|||
within find('.files-wrapper .diff-file.inline-view', text: 'files/ruby/popen.rb') do
|
||||
click_button 'Edit inline'
|
||||
wait_for_ajax
|
||||
execute_script('ace.edit($(".files-wrapper .diff-file.inline-view pre")[0]).setValue("One morning");');
|
||||
execute_script('ace.edit($(".files-wrapper .diff-file.inline-view pre")[0]).setValue("One morning");')
|
||||
end
|
||||
|
||||
within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do
|
||||
click_button 'Edit inline'
|
||||
wait_for_ajax
|
||||
execute_script('ace.edit($(".files-wrapper .diff-file.inline-view pre")[1]).setValue("Gregor Samsa woke from troubled dreams");');
|
||||
execute_script('ace.edit($(".files-wrapper .diff-file.inline-view pre")[1]).setValue("Gregor Samsa woke from troubled dreams");')
|
||||
end
|
||||
|
||||
click_button 'Commit conflict resolution'
|
||||
|
|
Loading…
Reference in New Issue