diff options
author | Tim Zallmann <tzallmann@gitlab.com> | 2018-03-23 11:45:43 +0100 |
---|---|---|
committer | Tim Zallmann <tzallmann@gitlab.com> | 2018-03-25 19:39:42 +0200 |
commit | 3a0dae8da215cd6964007bb41896fbc201a9dd20 (patch) | |
tree | 3d786e6820d3e22034f26ef1c46e206854b24e46 | |
parent | 391732a2c1b04baf565c77f2788a1ec035b1d85e (diff) | |
download | gitlab-ce-3a0dae8da215cd6964007bb41896fbc201a9dd20.tar.gz |
Basic Setup for MR Showing
23 files changed, 1006 insertions, 314 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index cbcefb2c18f..bed20b36868 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -10,6 +10,9 @@ const Api = { projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', projectLabelsPath: '/:namespace_path/:project_path/labels', + mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', + mergeRequestChangesPath: + '/api/:version/projects/:id/merge_requests/:mrid/changes', groupLabelsPath: '/groups/:namespace_path/-/labels', licensePath: '/api/:version/templates/licenses/:key', gitignorePath: '/api/:version/templates/gitignores/:key', @@ -22,25 +25,27 @@ const Api = { createBranchPath: '/api/:version/projects/:id/repository/branches', group(groupId, callback) { - const url = Api.buildUrl(Api.groupPath) - .replace(':id', groupId); - return axios.get(url) - .then(({ data }) => { - callback(data); + const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); + return axios.get(url).then(({ data }) => { + callback(data); - return data; - }); + return data; + }); }, // Return groups list. Filtered by query groups(query, options, callback = $.noop) { const url = Api.buildUrl(Api.groupsPath); - return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), - }) + return axios + .get(url, { + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), + }) .then(({ data }) => { callback(data); @@ -51,12 +56,13 @@ const Api = { // Return namespaces list. Filtered by query namespaces(query, callback) { const url = Api.buildUrl(Api.namespacesPath); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, @@ -73,9 +79,10 @@ const Api = { defaults.membership = true; } - return axios.get(url, { - params: Object.assign(defaults, options), - }) + return axios + .get(url, { + params: Object.assign(defaults, options), + }) .then(({ data }) => { callback(data); @@ -85,8 +92,28 @@ const Api = { // Return single project project(projectPath) { - const url = Api.buildUrl(Api.projectPath) - .replace(':id', encodeURIComponent(projectPath)); + const url = Api.buildUrl(Api.projectPath).replace( + ':id', + encodeURIComponent(projectPath), + ); + + return axios.get(url); + }, + + // Return Merge Request for project + mergeRequest(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); + + return axios.get(url); + }, + + // Return Merge Request Changes + mergeRequestChanges(projectPath, mergeRequestId) { + const url = Api.buildUrl(Api.mergeRequestChangesPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':mrid', mergeRequestId); return axios.get(url); }, @@ -99,33 +126,39 @@ const Api = { .replace(':namespace_path', namespacePath) .replace(':project_path', projectPath); } else { - url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath); + url = Api.buildUrl(Api.groupLabelsPath).replace( + ':namespace_path', + namespacePath, + ); } - return axios.post(url, { - label: data, - }) + return axios + .post(url, { + label: data, + }) .then(res => callback(res.data)) .catch(e => callback(e.response.data)); }, // Return group projects list. Filtered by query groupProjects(groupId, query, callback) { - const url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', groupId); - return axios.get(url, { - params: { - search: query, - per_page: 20, - }, - }) + const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId); + return axios + .get(url, { + params: { + search: query, + per_page: 20, + }, + }) .then(({ data }) => callback(data)); }, commitMultiple(id, data) { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions - const url = Api.buildUrl(Api.commitPath) - .replace(':id', encodeURIComponent(id)); + const url = Api.buildUrl(Api.commitPath).replace( + ':id', + encodeURIComponent(id), + ); return axios.post(url, JSON.stringify(data), { headers: { 'Content-Type': 'application/json; charset=utf-8', @@ -136,39 +169,34 @@ const Api = { branchSingle(id, branch) { const url = Api.buildUrl(Api.branchSinglePath) .replace(':id', encodeURIComponent(id)) - .replace(':branch', branch); + .replace(':branch', encodeURIComponent(branch)); return axios.get(url); }, // Return text for a specific license licenseText(key, data, callback) { - const url = Api.buildUrl(Api.licensePath) - .replace(':key', key); - return axios.get(url, { - params: data, - }) + const url = Api.buildUrl(Api.licensePath).replace(':key', key); + return axios + .get(url, { + params: data, + }) .then(res => callback(res.data)); }, gitignoreText(key, callback) { - const url = Api.buildUrl(Api.gitignorePath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitignorePath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, gitlabCiYml(key, callback) { - const url = Api.buildUrl(Api.gitlabCiYmlPath) - .replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + const url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key); + return axios.get(url).then(({ data }) => callback(data)); }, dockerfileYml(key, callback) { const url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); - return axios.get(url) - .then(({ data }) => callback(data)); + return axios.get(url).then(({ data }) => callback(data)); }, issueTemplate(namespacePath, projectPath, key, type, callback) { @@ -177,7 +205,8 @@ const Api = { .replace(':type', type) .replace(':project_path', projectPath) .replace(':namespace_path', namespacePath); - return axios.get(url) + return axios + .get(url) .then(({ data }) => callback(null, data)) .catch(callback); }, @@ -185,10 +214,13 @@ const Api = { users(query, options) { const url = Api.buildUrl(this.usersPath); return axios.get(url, { - params: Object.assign({ - search: query, - per_page: 20, - }, options), + params: Object.assign( + { + search: query, + per_page: 20, + }, + options, + ), }); }, diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index 170347881e0..42b00b5d9df 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,31 +1,36 @@ <script> - import Icon from '~/vue_shared/components/icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; - export default { - components: { - Icon, +export default { + components: { + Icon, + }, + props: { + hasChanges: { + type: Boolean, + required: false, + default: false, }, - props: { - hasChanges: { - type: Boolean, - required: false, - default: false, - }, - viewer: { - type: String, - required: true, - }, - showShadow: { - type: Boolean, - required: true, - }, + hasMergeRequest: { + type: Boolean, + required: false, + default: false, }, - methods: { - changeMode(mode) { - this.$emit('click', mode); - }, + viewer: { + type: String, + required: true, }, - }; + showShadow: { + type: Boolean, + required: true, + }, + }, + methods: { + changeMode(mode) { + this.$emit('click', mode); + }, + }, +}; </script> <template> @@ -43,7 +48,10 @@ }" data-toggle="dropdown" > - <template v-if="viewer === 'editor'"> + <template v-if="viewer === 'mrdiff'"> + {{ __('Reviewing (merge request)') }} + </template> + <template v-else-if="viewer === 'editor'"> {{ __('Editing') }} </template> <template v-else> @@ -57,6 +65,21 @@ </button> <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> <ul> + <li v-if="hasMergeRequest"> + <a + href="#" + @click.prevent="changeMode('mrdiff')" + :class="{ + 'is-active': viewer === 'mrdiff', + }" + > + <strong class="dropdown-menu-inner-title">{{ __('Reviewing (merge request)') }}</strong> + <span class="dropdown-menu-inner-content"> + {{ __('Compare changes of the merge request') }} + </span> + </a> + </li> + <li v-if="hasMergeRequest" role="separator" class="divider"></li> <li> <a href="#" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 015e750525a..f300afa24ac 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,51 +1,51 @@ <script> - import { mapState, mapGetters } from 'vuex'; - import ideSidebar from './ide_side_bar.vue'; - import ideContextbar from './ide_context_bar.vue'; - import repoTabs from './repo_tabs.vue'; - import repoFileButtons from './repo_file_buttons.vue'; - import ideStatusBar from './ide_status_bar.vue'; - import repoEditor from './repo_editor.vue'; +import { mapState, mapGetters } from 'vuex'; +import ideSidebar from './ide_side_bar.vue'; +import ideContextbar from './ide_context_bar.vue'; +import repoTabs from './repo_tabs.vue'; +import repoFileButtons from './repo_file_buttons.vue'; +import ideStatusBar from './ide_status_bar.vue'; +import repoEditor from './repo_editor.vue'; - export default { - components: { - ideSidebar, - ideContextbar, - repoTabs, - repoFileButtons, - ideStatusBar, - repoEditor, +export default { + components: { + ideSidebar, + ideContextbar, + repoTabs, + repoFileButtons, + ideStatusBar, + repoEditor, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, }, - props: { - emptyStateSvgPath: { - type: String, - required: true, - }, - noChangesStateSvgPath: { - type: String, - required: true, - }, - committedStateSvgPath: { - type: String, - required: true, - }, + noChangesStateSvgPath: { + type: String, + required: true, }, - computed: { - ...mapState(['changedFiles', 'openFiles', 'viewer']), - ...mapGetters(['activeFile', 'hasChanges']), + committedStateSvgPath: { + type: String, + required: true, }, - mounted() { - const returnValue = 'Are you sure you want to lose unsaved changes?'; - window.onbeforeunload = e => { - if (!this.changedFiles.length) return undefined; + }, + computed: { + ...mapState(['changedFiles', 'openFiles', 'viewer']), + ...mapGetters(['activeFile', 'hasChanges', 'hasMergeRequest']), + }, + mounted() { + const returnValue = 'Are you sure you want to lose unsaved changes?'; + window.onbeforeunload = e => { + if (!this.changedFiles.length) return undefined; - Object.assign(e, { - returnValue, - }); - return returnValue; - }; - }, - }; + Object.assign(e, { + returnValue, + }); + return returnValue; + }; + }, +}; </script> <template> @@ -63,6 +63,7 @@ :files="openFiles" :viewer="viewer" :has-changes="hasChanges" + :has-merge-request="hasMergeRequest" /> <repo-editor class="multi-file-edit-pane-content" diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index e73d1ce839f..d647d3b87fb 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -70,7 +70,9 @@ export default { this.getRawFileData(this.file) .then(() => { - const viewerPromise = this.delayViewerUpdated ? this.updateViewer('editor') : Promise.resolve(); + const viewerPromise = this.delayViewerUpdated + ? this.updateViewer('editor') + : Promise.resolve(); return viewerPromise; }) @@ -78,8 +80,15 @@ export default { this.updateDelayViewerUpdated(false); this.createEditorInstance(); }) - .catch((err) => { - flash('Error setting up monaco. Please try again.', 'alert', document, null, false, true); + .catch(err => { + flash( + 'Error setting up monaco. Please try again.', + 'alert', + document, + null, + false, + true, + ); throw err; }); }, @@ -101,9 +110,13 @@ export default { this.model = this.editor.createModel(this.file); - this.editor.attachModel(this.model); + if (this.viewer === 'mrdiff') { + this.editor.attachMergeRequestModel(this.model); + } else { + this.editor.attachModel(this.model); + } - this.model.onChange((model) => { + this.model.onChange(model => { const { file } = model; if (file.active) { diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index 8ea64ddf84a..37e90d3ba7e 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,42 +1,47 @@ <script> - import { mapActions } from 'vuex'; - import RepoTab from './repo_tab.vue'; - import EditorMode from './editor_mode_dropdown.vue'; +import { mapActions } from 'vuex'; +import RepoTab from './repo_tab.vue'; +import EditorMode from './editor_mode_dropdown.vue'; - export default { - components: { - RepoTab, - EditorMode, +export default { + components: { + RepoTab, + EditorMode, + }, + props: { + files: { + type: Array, + required: true, }, - props: { - files: { - type: Array, - required: true, - }, - viewer: { - type: String, - required: true, - }, - hasChanges: { - type: Boolean, - required: true, - }, + viewer: { + type: String, + required: true, }, - data() { - return { - showShadow: false, - }; + hasChanges: { + type: Boolean, + required: true, }, - updated() { - if (!this.$refs.tabsScroller) return; - - this.showShadow = - this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; - }, - methods: { - ...mapActions(['updateViewer']), + hasMergeRequest: { + type: Boolean, + required: true, + default: false, }, - }; + }, + data() { + return { + showShadow: false, + }; + }, + updated() { + if (!this.$refs.tabsScroller) return; + + this.showShadow = + this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth; + }, + methods: { + ...mapActions(['updateViewer']), + }, +}; </script> <template> @@ -55,6 +60,7 @@ :viewer="viewer" :show-shadow="showShadow" :has-changes="hasChanges" + :has-merge-request="hasMergeRequest" @click="updateViewer" /> </div> diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index db89c1d44db..f054a0a0364 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import flash from '~/flash'; import store from './stores'; +import { getTreeEntry } from './stores/utils'; Vue.use(VueRouter); @@ -44,7 +45,7 @@ const router = new VueRouter({ component: EmptyRouterComponent, }, { - path: 'mr/:mrid', + path: 'merge_requests/:mrid', component: EmptyRouterComponent, }, ], @@ -96,6 +97,84 @@ router.beforeEach((to, from, next) => { ); throw e; }); + } else if (to.params.mrid) { + store.dispatch('updateViewer', 'mrdiff'); + + store + .dispatch('getMergeRequestData', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }) + .then(mr => { + store.dispatch('getBranchData', { + projectId: fullProjectId, + branchId: mr.source_branch, + }); + + store + .dispatch('getFiles', { + projectId: fullProjectId, + branchId: mr.source_branch, + }) + .then(() => { + store + .dispatch('getMergeRequestChanges', { + projectId: fullProjectId, + mergeRequestId: to.params.mrid, + }) + .then(mrChanges => { + if (mrChanges.changes.length > 0) { + } + mrChanges.changes.forEach((change, ind) => { + console.log(`CHANGE : ${ind} : `, change); + + const changeTreeEntry = + store.state.entries[change.new_path]; + + console.log( + 'Tree Entry for the change ', + changeTreeEntry, + change.diff, + ); + + if (changeTreeEntry) { + store.dispatch('setFileMrDiff', { + file: changeTreeEntry, + mrDiff: change.diff, + }); + store.dispatch('setFileTargetBranch', { + file: changeTreeEntry, + targetBranch: mrChanges.target_branch, + }); + + if (ind === 0) { + store.dispatch('getFileData', change.new_path); + } else { + // TODO : Implement Tab reloading + store.dispatch('preloadFileTab', changeTreeEntry); + } + } else { + console.warn(`No Tree Entry for ${change.new_path}`); + } + }); + }) + .catch(e => { + flash( + 'Error while loading the merge request changes. Please try again.', + ); + throw e; + }); + }) + .catch(e => { + flash( + 'Error while loading the branch files. Please try again.', + ); + throw e; + }); + }) + .catch(e => { + throw e; + }); } }) .catch(e => { diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js index 73cd684351c..8e16df99a03 100644 --- a/app/assets/javascripts/ide/lib/common/model.js +++ b/app/assets/javascripts/ide/lib/common/model.js @@ -22,6 +22,16 @@ export default class Model { )), ); + if (this.file.targetBranch) { + this.disposable.add( + (this.targetModel = this.monaco.editor.createModel( + this.file.targetRaw, + undefined, + new this.monaco.Uri(null, null, `target/${this.file.path}`), + )), + ); + } + this.events = new Map(); this.updateContent = this.updateContent.bind(this); @@ -58,6 +68,10 @@ export default class Model { return this.originalModel; } + getTargetModel() { + return this.targetModel; + } + setValue(value) { this.getModel().setValue(value); } diff --git a/app/assets/javascripts/ide/lib/diff/revert_patch.js b/app/assets/javascripts/ide/lib/diff/revert_patch.js new file mode 100644 index 00000000000..21c90adedd8 --- /dev/null +++ b/app/assets/javascripts/ide/lib/diff/revert_patch.js @@ -0,0 +1,183 @@ +export function revertPatch(source, uniDiff, options = {}) { + if (typeof uniDiff === 'string') { + uniDiff = parsePatch(uniDiff); + } + + if (Array.isArray(uniDiff)) { + if (uniDiff.length > 1) { + throw new Error('applyPatch only works with a single input.'); + } + + uniDiff = uniDiff[0]; + } + + // Apply the diff to the input + let lines = source.split(/\r\n|[\n\v\f\r\x85]/), + delimiters = source.match(/\r\n|[\n\v\f\r\x85]/g) || [], + hunks = uniDiff.hunks, + compareLine = + options.compareLine || + ((lineNumber, line, operation, patchContent) => line === patchContent), + errorCount = 0, + fuzzFactor = options.fuzzFactor || 0, + minLine = 0, + offset = 0, + removeEOFNL, + addEOFNL; + + /** + * Checks if the hunk exactly fits on the provided location + */ + function hunkFits(hunk, toPos) { + for (let j = 0; j < hunk.lines.length; j++) { + let line = hunk.lines[j], + operation = line[0], + content = line.substr(1); + + if (operation === ' ' || operation === '-') { + // Context sanity check + if (!compareLine(toPos + 1, lines[toPos], operation, content)) { + errorCount++; + + if (errorCount > fuzzFactor) { + return false; + } + } + toPos++; + } + } + + return true; + } + + // Search best fit offsets for each hunk based on the previous ones + for (let i = 0; i < hunks.length; i++) { + let hunk = hunks[i], + maxLine = lines.length - hunk.oldLines, + localOffset = 0, + toPos = offset + hunk.oldStart - 1; + + const iterator = distanceIterator(toPos, minLine, maxLine); + + for (; localOffset !== undefined; localOffset = iterator()) { + if (hunkFits(hunk, toPos + localOffset)) { + hunk.offset = offset += localOffset; + break; + } + } + + if (localOffset === undefined) { + return false; + } + + // Set lower text limit to end of the current hunk, so next ones don't try + // to fit over already patched text + minLine = hunk.offset + hunk.oldStart + hunk.oldLines; + } + + // Apply patch hunks + let diffOffset = 0; + for (let i = 0; i < hunks.length; i++) { + let hunk = hunks[i], + toPos = hunk.oldStart + hunk.offset + diffOffset - 1; + diffOffset += hunk.newLines - hunk.oldLines; + + if (toPos < 0) { + // Creating a new file + toPos = 0; + } + + for (let j = 0; j < hunk.lines.length; j++) { + let line = hunk.lines[j], + operation = line[0], + content = line.substr(1), + delimiter = hunk.linedelimiters[j]; + + // Turned around the commands to revert the applying + if (operation === ' ') { + toPos++; + } else if (operation === '+') { + lines.splice(toPos, 1); + delimiters.splice(toPos, 1); + /* istanbul ignore else */ + } else if (operation === '-') { + lines.splice(toPos, 0, content); + delimiters.splice(toPos, 0, delimiter); + toPos++; + } else if (operation === '\\') { + const previousOperation = hunk.lines[j - 1] + ? hunk.lines[j - 1][0] + : null; + if (previousOperation === '+') { + removeEOFNL = true; + } else if (previousOperation === '-') { + addEOFNL = true; + } + } + } + } + + // Handle EOFNL insertion/removal + if (removeEOFNL) { + while (!lines[lines.length - 1]) { + lines.pop(); + delimiters.pop(); + } + } else if (addEOFNL) { + lines.push(''); + delimiters.push('\n'); + } + for (let _k = 0; _k < lines.length - 1; _k++) { + lines[_k] = lines[_k] + delimiters[_k]; + } + return lines.join(''); +} + +/** + * Utility Function + * @param {*} start + * @param {*} minLine + * @param {*} maxLine + */ +const distanceIterator = function(start, minLine, maxLine) { + let wantForward = true, + backwardExhausted = false, + forwardExhausted = false, + localOffset = 1; + + return function iterator() { + if (wantForward && !forwardExhausted) { + if (backwardExhausted) { + localOffset++; + } else { + wantForward = false; + } + + // Check if trying to fit beyond text length, and if not, check it fits + // after offset location (or desired location on first iteration) + if (start + localOffset <= maxLine) { + return localOffset; + } + + forwardExhausted = true; + } + + if (!backwardExhausted) { + if (!forwardExhausted) { + wantForward = true; + } + + // Check if trying to fit before text beginning, and if not, check it fits + // before offset location + if (minLine <= start - localOffset) { + return -localOffset++; + } + + backwardExhausted = true; + return iterator(); + } + + // We tried to fit hunk before text beginning and beyond text length, then + // hunk can't fit on the text. Return undefined + }; +}; diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 38de2fe2b27..dcde33399e9 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -105,6 +105,13 @@ export default class Editor { if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model); } + attachMergeRequestModel(model) { + this.instance.setModel({ + original: model.getTargetModel(), + modified: model.getModel(), + }); + } + setupMonacoTheme() { this.monaco.editor.defineTheme( gitlabTheme.themeName, diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 5f1fb6cf843..46a65c583e0 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -20,12 +20,19 @@ export default { return Promise.resolve(file.raw); } - return Vue.http.get(file.rawPath, { params: { format: 'json' } }) + return Vue.http + .get(file.rawPath, { params: { format: 'json' } }) .then(res => res.text()); }, getProjectData(namespace, project) { return Api.project(`${namespace}/${project}`); }, + getProjectMergeRequestData(projectId, mergeRequestId) { + return Api.mergeRequest(projectId, mergeRequestId); + }, + getProjectMergeRequestChanges(projectId, mergeRequestId) { + return Api.mergeRequestChanges(projectId, mergeRequestId); + }, getBranchData(projectId, currentBranchId) { return Api.branchSingle(projectId, currentBranchId); }, diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 7e920aa9f30..83e200f3cc0 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -119,3 +119,4 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => { export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; +export * from './actions/merge_request'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index ddc4b757bf9..57e036f57af 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,10 +1,12 @@ import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { parsePatch, applyPatches } from 'diff'; +import { revertPatch } from '../../lib/diff/revert_patch'; import flash from '~/flash'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { setPageTitle } from '../utils'; +import { setPageTitle, createTemp, findIndexOfFile } from '../utils'; export const closeFile = ({ commit, state, getters, dispatch }, path) => { const indexOfClosedFile = state.openFiles.findIndex(f => f.path === path); @@ -46,53 +48,140 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { commit(types.SET_CURRENT_BRANCH, file.branchId); }; -export const getFileData = ({ state, commit, dispatch }, file) => { - commit(types.TOGGLE_LOADING, { entry: file }); - - return service - .getFileData(file.url) - .then(res => { - const pageTitle = decodeURI(normalizeHeaders(res.headers)['PAGE-TITLE']); - - setPageTitle(pageTitle); - - return res.json(); - }) - .then(data => { - commit(types.SET_FILE_DATA, { data, file }); - commit(types.TOGGLE_FILE_OPEN, file.path); - dispatch('setFileActive', file.path); - commit(types.TOGGLE_LOADING, { entry: file }); - }) - .catch(() => { - commit(types.TOGGLE_LOADING, { entry: file }); - flash( - 'Error loading file data. Please try again.', - 'alert', - document, - null, - false, - true, - ); - }); +export const getFileData = ({ state, commit, dispatch }, path) => { + const file = state.entries[path]; + return new Promise((resolve, reject) => { + commit(types.TOGGLE_LOADING, { entry: file }); + service + .getFileData(file.url) + .then(res => { + const pageTitle = decodeURI( + normalizeHeaders(res.headers)['PAGE-TITLE'], + ); + + setPageTitle(pageTitle); + + return res.json(); + }) + .then(data => { + commit(types.SET_FILE_DATA, { data, file }); + commit(types.TOGGLE_FILE_OPEN, path); + dispatch('setFileActive', file.path); + commit(types.TOGGLE_LOADING, { entry: file }); + }) + .catch(err => { + console.log('Error : ', err); + commit(types.TOGGLE_LOADING, { entry: file }); + flash( + 'Error loading file data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + }); + }); +}; + +export const preloadFileTab = ({ state, commit, dispatch }, file) => { + return new Promise((resolve, reject) => { + commit(types.TOGGLE_LOADING, { entry: file }); + service + .getFileData(file.url) + .then(data => { + commit(types.SET_FILE_DATA, { data, file }); + commit(types.TOGGLE_FILE_OPEN, file); + commit(types.TOGGLE_LOADING, { entry: file }); + }) + .catch(() => { + commit(types.TOGGLE_LOADING, { entry: file }); + flash( + 'Error loading file data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + }); + }); +}; + +export const setFileTargetBranch = ( + { state, commit }, + { file, targetBranch }, +) => { + commit(types.SET_FILE_TARGET_BRANCH, { + file, + targetBranch, + targetRawPath: file.rawPath.replace(file.branchId, targetBranch), + }); +}; + +export const processFileMrDiff = ({ state, commit }, file) => { + const patchObj = parsePatch(file.mrDiff); + const transformedContent = applyPatch(file.raw, file.mrDiff); + debugger; }; -export const getRawFileData = ({ commit, dispatch }, file) => - service - .getRawFileData(file) - .then(raw => { - commit(types.SET_FILE_RAW_DATA, { file, raw }); - }) - .catch(() => - flash( - 'Error loading file content. Please try again.', - 'alert', - document, - null, - false, - true, - ), - ); +export const setFileMrDiff = ({ state, commit }, { file, mrDiff }) => { + commit(types.SET_FILE_MR_DIFF, { file, mrDiff }); +}; + +export const getRawFileData = ({ commit, dispatch }, file) => { + return new Promise((resolve, reject) => { + service + .getRawFileData(file) + .then(raw => { + commit(types.SET_FILE_RAW_DATA, { file, raw }); + if (file.mrDiff) { + const patchObj = parsePatch(file.mrDiff); + patchObj[0].hunks.forEach(hunk => { + console.log('H ', hunk); + /*hunk.lines.forEach((line) => { + if (line.substr(0, 1) === '+') { + line = '-' + line.substr(1); + } else if (line.substr(0, 1) === '-') { + line = '+' + line.substr(1); + } + })*/ + }); + + console.log('PATCH OBJ : ' + JSON.stringify(patchObj)); + + const transformedContent = revertPatch(raw, patchObj, { + compareLine: (lineNumber, line, operation, patchContent) => { + const tempLine = line; + //line = patchContent; + //patchContent = tempLine; + if (operation === '-') { + operation = '+'; + } else if (operation === '+') { + operation = '-'; + } + console.log( + 'COMPARE : ' + line + ' - ' + operation + ' - ' + patchContent, + ); + return true; + }, + }); + console.log('TRANSFORMED : ', transformedContent); + commit(types.SET_FILE_TARGET_RAW_DATA, { + file, + raw: transformedContent, + }); + resolve(raw); + } else { + resolve(raw); + } + }) + .catch(() => { + flash('Error loading file content. Please try again.'); + reject(); + }); + }); +}; export const changeFileContent = ({ state, commit }, { path, content }) => { const file = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js new file mode 100644 index 00000000000..6da00e98f59 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -0,0 +1,96 @@ +import flash from '~/flash'; +import service from '../../services'; +import * as types from '../mutation_types'; + +// eslint-disable-next-line import/prefer-default-export +export const getMergeRequestData = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if (!state.projects[projectId].mergeRequests[mergeRequestId] || force) { + service + .getProjectMergeRequestData(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST, { + projectPath: projectId, + mergeRequestId, + mergeRequest: data, + }); + if (!state.currentMergeRequestId) { + commit( + types.SET_CURRENT_MERGE_REQUEST, + `${projectId}/${mergeRequestId}`, + ); + } + resolve(data); + }) + .catch(() => { + flash('Error loading merge request data. Please try again.'); + reject(new Error(`Merge Request not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId]); + } + }); + +// eslint-disable-next-line import/prefer-default-export +export const getMergeRequestChanges = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if ( + !state.projects[projectId].mergeRequests[mergeRequestId].changes || + force + ) { + service + .getProjectMergeRequestChanges(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_CHANGES, { + projectPath: projectId, + mergeRequestId, + changes: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request changes. Please try again.'); + reject(new Error(`Merge Request Changes not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes); + } + }); + +// eslint-disable-next-line import/prefer-default-export +export const getMergeRequestNotes = ( + { commit, state, dispatch }, + { projectId, mergeRequestId, force = false } = {}, +) => + new Promise((resolve, reject) => { + if ( + !state.projects[projectId].mergeRequests[mergeRequestId].notes || + force + ) { + service + .getProjectMergeRequestNotes(projectId, mergeRequestId) + .then(res => res.data) + .then(data => { + commit(types.SET_MERGE_REQUEST_NOTES, { + projectPath: projectId, + mergeRequestId, + notes: data, + }); + resolve(data); + }) + .catch(() => { + flash('Error loading merge request notes. Please try again.'); + reject(new Error(`Merge Request Notes not loaded ${projectId}`)); + }); + } else { + resolve(state.projects[projectId].mergeRequests[mergeRequestId].notes); + } + }); diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 70a969a0325..4a960a12010 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -2,9 +2,7 @@ import { normalizeHeaders } from '~/lib/utils/common_utils'; import flash from '~/flash'; import service from '../../services'; import * as types from '../mutation_types'; -import { - findEntry, -} from '../utils'; +import { findEntry } from '../utils'; import FilesDecoratorWorker from '../workers/files_decorator_worker'; export const toggleTreeOpen = ({ commit, dispatch }, path) => { @@ -21,24 +19,33 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => { dispatch('setFileActive', row.path); } else { - dispatch('getFileData', row); + dispatch('getFileData', row.path); } }; -export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = state) => { +export const getLastCommitData = ( + { state, commit, dispatch, getters }, + tree = state, +) => { if (!tree || tree.lastCommitPath === null || !tree.lastCommitPath) return; - service.getTreeLastCommit(tree.lastCommitPath) - .then((res) => { - const lastCommitPath = normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; + service + .getTreeLastCommit(tree.lastCommitPath) + .then(res => { + const lastCommitPath = + normalizeHeaders(res.headers)['MORE-LOGS-URL'] || null; commit(types.SET_LAST_COMMIT_URL, { tree, url: lastCommitPath }); return res.json(); }) - .then((data) => { - data.forEach((lastCommit) => { - const entry = findEntry(tree.tree, lastCommit.type, lastCommit.file_name); + .then(data => { + data.forEach(lastCommit => { + const entry = findEntry( + tree.tree, + lastCommit.type, + lastCommit.file_name, + ); if (entry) { commit(types.SET_LAST_COMMIT_DATA, { entry, lastCommit }); @@ -47,47 +54,62 @@ export const getLastCommitData = ({ state, commit, dispatch, getters }, tree = s dispatch('getLastCommitData', tree); }) - .catch(() => flash('Error fetching log data.', 'alert', document, null, false, true)); + .catch(() => + flash('Error fetching log data.', 'alert', document, null, false, true), + ); }; export const getFiles = ( { state, commit, dispatch }, { projectId, branchId } = {}, -) => new Promise((resolve, reject) => { - if (!state.trees[`${projectId}/${branchId}`]) { - const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); - - service - .getFiles(selectedProject.web_url, branchId) - .then(res => res.json()) - .then((data) => { - const worker = new FilesDecoratorWorker(); - worker.addEventListener('message', (e) => { - const { entries, treeList } = e.data; - const selectedTree = state.trees[`${projectId}/${branchId}`]; - - commit(types.SET_ENTRIES, entries); - commit(types.SET_DIRECTORY_DATA, { treePath: `${projectId}/${branchId}`, data: treeList }); - commit(types.TOGGLE_LOADING, { entry: selectedTree, forceValue: false }); - - worker.terminate(); - - resolve(); - }); - - worker.postMessage({ - data, - projectId, - branchId, +) => + new Promise((resolve, reject) => { + if (!state.trees[`${projectId}/${branchId}`]) { + const selectedProject = state.projects[projectId]; + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + + service + .getFiles(selectedProject.web_url, branchId) + .then(res => res.json()) + .then(data => { + const worker = new FilesDecoratorWorker(); + worker.addEventListener('message', e => { + const { entries, treeList } = e.data; + const selectedTree = state.trees[`${projectId}/${branchId}`]; + + commit(types.SET_ENTRIES, entries); + commit(types.SET_DIRECTORY_DATA, { + treePath: `${projectId}/${branchId}`, + data: treeList, + }); + commit(types.TOGGLE_LOADING, { + entry: selectedTree, + forceValue: false, + }); + + worker.terminate(); + + resolve(); + }); + + worker.postMessage({ + data, + projectId, + branchId, + }); + }) + .catch(e => { + flash( + 'Error loading tree data. Please try again.', + 'alert', + document, + null, + false, + true, + ); + reject(e); }); - }) - .catch((e) => { - flash('Error loading tree data. Please try again.', 'alert', document, null, false, true); - reject(e); - }); - } else { - resolve(); - } -}); - + } else { + resolve(); + } + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index eba325a31df..fab12e7e1c6 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -28,3 +28,5 @@ export const currentIcon = state => state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right'; export const hasChanges = state => !!state.changedFiles.length; + +export const hasMergeRequest = state => !!state.currentMergeRequestId; diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index e28f190897c..4edbc58ca40 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -11,6 +11,12 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN'; +// Merge Request Mutation Types +export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; +export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST'; +export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES'; +export const SET_MERGE_REQUEST_NOTES = 'SET_MERGE_REQUEST_NOTES'; + // Branch Mutation Types export const SET_BRANCH = 'SET_BRANCH'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; @@ -28,6 +34,7 @@ export const SET_FILE_DATA = 'SET_FILE_DATA'; export const TOGGLE_FILE_OPEN = 'TOGGLE_FILE_OPEN'; export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; +export const SET_FILE_TARGET_RAW_DATA = 'SET_FILE_TARGET_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_POSITION = 'SET_FILE_POSITION'; @@ -39,5 +46,7 @@ export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED'; export const SET_CURRENT_BRANCH = 'SET_CURRENT_BRANCH'; export const SET_ENTRIES = 'SET_ENTRIES'; export const CREATE_TMP_ENTRY = 'CREATE_TMP_ENTRY'; +export const SET_FILE_MR_DIFF = 'SET_FILE_MR_DIFF'; +export const SET_FILE_TARGET_BRANCH = 'SET_FILE_TARGET_BRANCH'; export const UPDATE_VIEWER = 'UPDATE_VIEWER'; export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index da41fc9285c..a3b9d0dac8c 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import projectMutations from './mutations/project'; +import mergeRequestMutation from './mutations/merge_request'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; @@ -100,6 +101,7 @@ export default { }); }, ...projectMutations, + ...mergeRequestMutation, ...fileMutations, ...treeMutations, ...branchMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 2500f13db7c..1297d3aaf1f 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -35,6 +35,11 @@ export default { raw, }); }, + [types.SET_FILE_TARGET_RAW_DATA](state, { file, raw }) { + Object.assign(file, { + targetRaw: raw, + }); + }, [types.UPDATE_FILE_CONTENT](state, { path, content }) { const changed = content !== state.entries[path].raw; @@ -59,6 +64,16 @@ export default { editorColumn, }); }, + [types.SET_FILE_MR_DIFF](state, { file, mrDiff }) { + Object.assign(file, { + mrDiff, + }); + }, + [types.SET_FILE_TARGET_BRANCH](state, { file, targetBranch }) { + Object.assign(file, { + targetBranch, + }); + }, [types.DISCARD_FILE_CHANGES](state, path) { Object.assign(state.entries[path], { content: state.entries[path].raw, diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js new file mode 100644 index 00000000000..69abe010372 --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js @@ -0,0 +1,40 @@ +import * as types from '../mutation_types'; + +export default { + [types.SET_CURRENT_MERGE_REQUEST](state, currentMergeRequestId) { + Object.assign(state, { + currentMergeRequestId, + }); + }, + [types.SET_MERGE_REQUEST]( + state, + { projectPath, mergeRequestId, mergeRequest }, + ) { + // Add client side properties + Object.assign(mergeRequest, { + active: true, + }); + + Object.assign(state.projects[projectPath], { + mergeRequests: { + [mergeRequestId]: mergeRequest, + }, + }); + }, + [types.SET_MERGE_REQUEST_CHANGES]( + state, + { projectPath, mergeRequestId, changes }, + ) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + changes, + }); + }, + [types.SET_MERGE_REQUEST_NOTES]( + state, + { projectPath, mergeRequestId, notes }, + ) { + Object.assign(state.projects[projectPath].mergeRequests[mergeRequestId], { + notes, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js index 2816562a919..284b39a2c72 100644 --- a/app/assets/javascripts/ide/stores/mutations/project.js +++ b/app/assets/javascripts/ide/stores/mutations/project.js @@ -11,6 +11,7 @@ export default { Object.assign(project, { tree: [], branches: {}, + mergeRequests: {}, active: true, }); diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 6110f54951c..e5cc8814000 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -1,6 +1,7 @@ export default () => ({ currentProjectId: '', currentBranchId: '', + currentMergeRequestId: '', changedFiles: [], endpoints: {}, lastCommitMsg: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 487ea1ead8e..cb0b1354665 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -38,7 +38,7 @@ export const dataStructure = () => ({ eol: '', }); -export const decorateData = (entity) => { +export const decorateData = entity => { const { id, projectId, @@ -57,7 +57,6 @@ export const decorateData = (entity) => { base64 = false, file_lock, - } = entity; return { @@ -80,17 +79,45 @@ export const decorateData = (entity) => { base64, file_lock, - }; }; -export const findEntry = (tree, type, name, prop = 'name') => tree.find( - f => f.type === type && f[prop] === name, -); +/* + Takes the multi-dimensional tree and returns a flattened array. + This allows for the table to recursively render the table rows but keeps the data + structure nested to make it easier to add new files/directories. +*/ +export const treeList = (state, treeId) => { + const baseTree = state.trees[treeId]; + if (baseTree) { + const mapTree = arr => + !arr.tree || !arr.tree.length + ? [] + : _.map(arr.tree, a => [a, mapTree(a)]); + + return _.chain(baseTree.tree) + .map(arr => [arr, mapTree(arr)]) + .flatten() + .value(); + } + return []; +}; + +export const getTree = state => (namespace, projectId, branch) => + state.trees[`${namespace}/${projectId}/${branch}`]; + +export const getTreeEntry = (store, treeId, path) => { + const fileList = treeList(store.state, treeId); + return fileList ? fileList.find(file => file.path === path) : null; +}; + +export const findEntry = (tree, type, name, prop = 'name') => + tree.find(f => f.type === type && f[prop] === name); -export const findIndexOfFile = (state, file) => state.findIndex(f => f.path === file.path); +export const findIndexOfFile = (state, file) => + state.findIndex(f => f.path === file.path); -export const setPageTitle = (title) => { +export const setPageTitle = title => { document.title = title; }; @@ -120,6 +147,11 @@ const sortTreesByTypeAndName = (a, b) => { return 0; }; -export const sortTree = sortedTree => sortedTree.map(entity => Object.assign(entity, { - tree: entity.tree.length ? sortTree(entity.tree) : [], -})).sort(sortTreesByTypeAndName); +export const sortTree = sortedTree => + sortedTree + .map(entity => + Object.assign(entity, { + tree: entity.tree.length ? sortTree(entity.tree) : [], + }), + ) + .sort(sortTreesByTypeAndName); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 3d886e7d628..d3d5ef76d2e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -1,53 +1,62 @@ <script> - import tooltip from '~/vue_shared/directives/tooltip'; - import { n__ } from '~/locale'; - import icon from '~/vue_shared/components/icon.vue'; - import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { n__ } from '~/locale'; +import icon from '~/vue_shared/components/icon.vue'; +import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; - export default { - name: 'MRWidgetHeader', - directives: { - tooltip, +export default { + name: 'MRWidgetHeader', + directives: { + tooltip, + }, + components: { + icon, + clipboardButton, + }, + props: { + mr: { + type: Object, + required: true, }, - components: { - icon, - clipboardButton, + }, + computed: { + shouldShowCommitsBehindText() { + return this.mr.divergedCommitsCount > 0; }, - props: { - mr: { - type: Object, - required: true, - }, + commitsText() { + return n__( + '%d commit behind', + '%d commits behind', + this.mr.divergedCommitsCount, + ); }, - computed: { - shouldShowCommitsBehindText() { - return this.mr.divergedCommitsCount > 0; - }, - commitsText() { - return n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount); - }, - branchNameClipboardData() { - // This supports code in app/assets/javascripts/copy_to_clipboard.js that - // works around ClipboardJS limitations to allow the context-specific - // copy/pasting of plain text or GFM. - return JSON.stringify({ - text: this.mr.sourceBranch, - gfm: `\`${this.mr.sourceBranch}\``, - }); - }, - isSourceBranchLong() { - return this.isBranchTitleLong(this.mr.sourceBranch); - }, - isTargetBranchLong() { - return this.isBranchTitleLong(this.mr.targetBranch); - }, + branchNameClipboardData() { + // This supports code in app/assets/javascripts/copy_to_clipboard.js that + // works around ClipboardJS limitations to allow the context-specific + // copy/pasting of plain text or GFM. + return JSON.stringify({ + text: this.mr.sourceBranch, + gfm: `\`${this.mr.sourceBranch}\``, + }); }, - methods: { - isBranchTitleLong(branchTitle) { - return branchTitle.length > 32; - }, + isSourceBranchLong() { + return this.isBranchTitleLong(this.mr.sourceBranch); }, - }; + isTargetBranchLong() { + return this.isBranchTitleLong(this.mr.targetBranch); + }, + webIdePath() { + return `${ + gon.relative_url_root + }/-/ide/project${this.mr.statusPath.replace('.json', '')}`; + }, + }, + methods: { + isBranchTitleLong(branchTitle) { + return branchTitle.length > 32; + }, + }, +}; </script> <template> <div class="mr-source-target"> @@ -96,6 +105,14 @@ </div> <div v-if="mr.isOpen"> + <a + :disabled="mr.sourceBranchRemoved" + :href="webIdePath" + class="btn btn-sm btn-default inline js-web-ide" + type="button" + > + {{ s__("mrWidget|Open in Web IDE") }} + </a> <button data-target="#modal_merge_info" data-toggle="modal" |