diff options
Diffstat (limited to 'app/assets/javascripts/diffs/components')
17 files changed, 2131 insertions, 0 deletions
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue new file mode 100644 index 00000000000..82ca10f4163 --- /dev/null +++ b/app/assets/javascripts/diffs/components/app.vue @@ -0,0 +1,197 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; +import CompareVersions from './compare_versions.vue'; +import ChangedFiles from './changed_files.vue'; +import DiffFile from './diff_file.vue'; +import NoChanges from './no_changes.vue'; +import HiddenFilesWarning from './hidden_files_warning.vue'; + +export default { + name: 'DiffsApp', + components: { + Icon, + LoadingIcon, + CompareVersions, + ChangedFiles, + DiffFile, + NoChanges, + HiddenFilesWarning, + }, + props: { + endpoint: { + type: String, + required: true, + }, + shouldShow: { + type: Boolean, + required: false, + default: false, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + activeFile: '', + }; + }, + computed: { + ...mapState({ + isLoading: state => state.diffs.isLoading, + diffFiles: state => state.diffs.diffFiles, + diffViewType: state => state.diffs.diffViewType, + mergeRequestDiffs: state => state.diffs.mergeRequestDiffs, + mergeRequestDiff: state => state.diffs.mergeRequestDiff, + latestVersionPath: state => state.diffs.latestVersionPath, + startVersion: state => state.diffs.startVersion, + commit: state => state.diffs.commit, + targetBranchName: state => state.diffs.targetBranchName, + renderOverflowWarning: state => state.diffs.renderOverflowWarning, + numTotalFiles: state => state.diffs.realSize, + numVisibleFiles: state => state.diffs.size, + plainDiffPath: state => state.diffs.plainDiffPath, + emailPatchPath: state => state.diffs.emailPatchPath, + }), + ...mapGetters(['isParallelView']), + targetBranch() { + return { + branchName: this.targetBranchName, + versionIndex: -1, + path: '', + }; + }, + notAllCommentsDisplayed() { + if (this.commit) { + return __('Only comments from the following commit are shown below'); + } else if (this.startVersion) { + return __( + "Not all comments are displayed because you're comparing two versions of the diff.", + ); + } + return __( + "Not all comments are displayed because you're viewing an old version of the diff.", + ); + }, + showLatestVersion() { + if (this.commit) { + return __('Show latest version of the diff'); + } + return __('Show latest version'); + }, + }, + watch: { + diffViewType() { + this.adjustView(); + }, + shouldShow() { + this.adjustView(); + }, + }, + mounted() { + this.setEndpoint(this.endpoint); + this + .fetchDiffFiles() + .catch(() => { + createFlash(__('Something went wrong on our end. Please try again!')); + }); + }, + created() { + this.adjustView(); + }, + methods: { + ...mapActions(['setEndpoint', 'fetchDiffFiles']), + setActive(filePath) { + this.activeFile = filePath; + }, + unsetActive(filePath) { + if (this.activeFile === filePath) { + this.activeFile = ''; + } + }, + adjustView() { + if (this.shouldShow && this.isParallelView) { + window.mrTabs.expandViewContainer(); + } else { + window.mrTabs.resetViewContainer(); + } + }, + }, +}; +</script> + +<template> + <div v-if="shouldShow"> + <div + v-if="isLoading" + class="loading" + > + <loading-icon /> + </div> + <div + v-else + id="diffs" + :class="{ active: shouldShow }" + class="diffs tab-pane" + > + <compare-versions + v-if="!commit && mergeRequestDiffs.length > 1" + :merge-request-diffs="mergeRequestDiffs" + :merge-request-diff="mergeRequestDiff" + :start-version="startVersion" + :target-branch="targetBranch" + /> + + <hidden-files-warning + v-if="renderOverflowWarning" + :visible="numVisibleFiles" + :total="numTotalFiles" + :plain-diff-path="plainDiffPath" + :email-patch-path="emailPatchPath" + /> + + <div + v-if="commit || startVersion || (mergeRequestDiff && !mergeRequestDiff.latest)" + class="mr-version-controls" + > + <div class="content-block comments-disabled-notif clearfix"> + <i class="fa fa-info-circle"></i> + {{ notAllCommentsDisplayed }} + <div class="pull-right"> + <a + :href="latestVersionPath" + class="btn btn-sm" + > + {{ showLatestVersion }} + </a> + </div> + </div> + </div> + + <changed-files + :diff-files="diffFiles" + :active-file="activeFile" + /> + + <div + v-if="diffFiles.length > 0" + class="files" + > + <diff-file + v-for="file in diffFiles" + :key="file.newPath" + :file="file" + :current-user="currentUser" + @setActive="setActive(file.filePath)" + @unsetActive="unsetActive(file.filePath)" + /> + </div> + <no-changes v-else /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/changed_files.vue b/app/assets/javascripts/diffs/components/changed_files.vue new file mode 100644 index 00000000000..c5ef9fefc2f --- /dev/null +++ b/app/assets/javascripts/diffs/components/changed_files.vue @@ -0,0 +1,184 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { pluralize } from '~/lib/utils/text_utility'; +import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; +import { contentTop } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import ChangedFilesDropdown from './changed_files_dropdown.vue'; +import changedFilesMixin from '../mixins/changed_files'; + +export default { + components: { + Icon, + ChangedFilesDropdown, + ClipboardButton, + }, + mixins: [changedFilesMixin], + props: { + activeFile: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isStuck: false, + maxWidth: 'auto', + offsetTop: 0, + }; + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView', 'areAllFilesCollapsed']), + sumAddedLines() { + return this.sumValues('addedLines'); + }, + sumRemovedLines() { + return this.sumValues('removedLines'); + }, + whitespaceVisible() { + return !getParameterValues('w')[0]; + }, + toggleWhitespaceText() { + if (this.whitespaceVisible) { + return __('Hide whitespace changes'); + } + return __('Show whitespace changes'); + }, + toggleWhitespacePath() { + if (this.whitespaceVisible) { + return mergeUrlParams({ w: 1 }, window.location.href); + } + + return mergeUrlParams({ w: 0 }, window.location.href); + }, + top() { + return `${this.offsetTop}px`; + }, + }, + created() { + document.addEventListener('scroll', this.handleScroll); + this.offsetTop = contentTop(); + }, + beforeDestroy() { + document.removeEventListener('scroll', this.handleScroll); + }, + methods: { + ...mapActions(['setInlineDiffViewType', 'setParallelDiffViewType', 'expandAllFiles']), + pluralize, + handleScroll() { + if (!this.updating) { + requestAnimationFrame(this.updateIsStuck); + this.updating = true; + } + }, + updateIsStuck() { + if (!this.$refs.wrapper) { + return; + } + + const scrollPosition = window.scrollY; + + this.isStuck = scrollPosition + this.offsetTop >= this.$refs.placeholder.offsetTop; + this.updating = false; + }, + sumValues(key) { + return this.diffFiles.reduce((total, file) => total + file[key], 0); + }, + }, +}; +</script> + +<template> + <span> + <div ref="placeholder"></div> + <div + ref="wrapper" + :style="{ top }" + :class="{'is-stuck': isStuck}" + class="content-block oneline-block diff-files-changed diff-files-changed-merge-request + files-changed js-diff-files-changed" + > + <div class="files-changed-inner"> + <div + class="inline-parallel-buttons d-none d-md-block" + > + <a + v-if="areAllFilesCollapsed" + class="btn btn-default" + @click="expandAllFiles" + > + {{ __('Expand all') }} + </a> + <a + :href="toggleWhitespacePath" + class="btn btn-default" + > + {{ toggleWhitespaceText }} + </a> + <div class="btn-group"> + <button + id="inline-diff-btn" + :class="{ active: isInlineView }" + type="button" + class="btn js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </button> + <button + id="parallel-diff-btn" + :class="{ active: isParallelView }" + type="button" + class="btn js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </button> + </div> + </div> + + <div class="commit-stat-summary dropdown"> + <changed-files-dropdown + :diff-files="diffFiles" + /> + + <span + v-show="activeFile" + class="prepend-left-5" + > + <strong class="prepend-right-5"> + {{ truncatedDiffPath(activeFile) }} + </strong> + <clipboard-button + :text="activeFile" + :title="s__('Copy file name to clipboard')" + tooltip-placement="bottom" + tooltip-container="body" + class="btn btn-default btn-transparent btn-clipboard" + /> + </span> + + <span + v-show="!isStuck" + id="diff-stats" + class="diff-stats-additions-deletions-expanded" + > + with + <strong class="cgreen"> + {{ pluralize(`${sumAddedLines} addition`, sumAddedLines) }} + </strong> + and + <strong class="cred"> + {{ pluralize(`${sumRemovedLines} deletion`, sumRemovedLines) }} + </strong> + </span> + </div> + </div> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/changed_files_dropdown.vue b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue new file mode 100644 index 00000000000..f224b9dd246 --- /dev/null +++ b/app/assets/javascripts/diffs/components/changed_files_dropdown.vue @@ -0,0 +1,124 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import changedFilesMixin from '../mixins/changed_files'; + +export default { + components: { + Icon, + }, + mixins: [changedFilesMixin], + data() { + return { + searchText: '', + }; + }, + computed: { + filteredDiffFiles() { + return this.diffFiles.filter(file => + file.filePath.toLowerCase().includes(this.searchText.toLowerCase()), + ); + }, + }, + methods: { + clearSearch() { + this.searchText = ''; + }, + }, +}; +</script> + +<template> + <span> + Showing + <button + class="diff-stats-summary-toggler" + data-toggle="dropdown" + type="button" + aria-expanded="false" + > + <span> + {{ n__('%d changed file', '%d changed files', diffFiles.length) }} + </span> + <icon + :size="8" + name="chevron-down" + /> + </button> + <div class="dropdown-menu diff-file-changes"> + <div class="dropdown-input"> + <input + v-model="searchText" + type="search" + class="dropdown-input-field" + placeholder="Search files" + autocomplete="off" + /> + <i + v-if="searchText.length === 0" + aria-hidden="true" + data-hidden="true" + class="fa fa-search dropdown-input-search"> + </i> + <i + v-else + role="button" + class="fa fa-times dropdown-input-search" + @click="clearSearch" + ></i> + </div> + <ul> + <li + v-for="diffFile in filteredDiffFiles" + :key="diffFile.name" + > + <a + :href="`#${diffFile.fileHash}`" + :title="diffFile.newPath" + class="diff-changed-file" + > + <icon + :name="fileChangedIcon(diffFile)" + :size="16" + :class="fileChangedClass(diffFile)" + class="diff-file-changed-icon append-right-8" + /> + <span class="diff-changed-file-content append-right-8"> + <strong + v-if="diffFile.blob && diffFile.blob.name" + class="diff-changed-file-name" + > + {{ diffFile.blob.name }} + </strong> + <strong + v-else + class="diff-changed-blank-file-name" + > + {{ s__('Diffs|No file name available') }} + </strong> + <span class="diff-changed-file-path prepend-top-5"> + {{ truncatedDiffPath(diffFile.blob.path) }} + </span> + </span> + <span class="diff-changed-stats"> + <span class="cgreen"> + +{{ diffFile.addedLines }} + </span> + <span class="cred"> + -{{ diffFile.removedLines }} + </span> + </span> + </a> + </li> + + <li + v-show="filteredDiffFiles.length === 0" + class="dropdown-menu-empty-item" + > + <a> + {{ __('No files found') }} + </a> + </li> + </ul> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue new file mode 100644 index 00000000000..1c9ad8e77f1 --- /dev/null +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -0,0 +1,55 @@ +<script> +import CompareVersionsDropdown from './compare_versions_dropdown.vue'; + +export default { + components: { + CompareVersionsDropdown, + }, + props: { + mergeRequestDiffs: { + type: Array, + required: true, + }, + mergeRequestDiff: { + type: Object, + required: true, + }, + startVersion: { + type: Object, + required: false, + default: null, + }, + targetBranch: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + comparableDiffs() { + return this.mergeRequestDiffs.slice(1); + }, + }, +}; +</script> + +<template> + <div class="mr-version-controls"> + <div class="mr-version-menus-container content-block"> + Changes between + <compare-versions-dropdown + :other-versions="mergeRequestDiffs" + :merge-request-version="mergeRequestDiff" + :show-commit-count="true" + class="mr-version-dropdown" + /> + and + <compare-versions-dropdown + :other-versions="comparableDiffs" + :start-version="startVersion" + :target-branch="targetBranch" + class="mr-version-compare-dropdown" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue new file mode 100644 index 00000000000..96cccb49378 --- /dev/null +++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue @@ -0,0 +1,165 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { n__, __ } from '~/locale'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + Icon, + TimeAgo, + }, + props: { + otherVersions: { + type: Array, + required: false, + default: () => [], + }, + mergeRequestVersion: { + type: Object, + required: false, + default: null, + }, + startVersion: { + type: Object, + required: false, + default: null, + }, + targetBranch: { + type: Object, + required: false, + default: null, + }, + showCommitCount: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + baseVersion() { + return { + name: 'hii', + versionIndex: -1, + }; + }, + targetVersions() { + if (this.mergeRequestVersion) { + return this.otherVersions; + } + return [...this.otherVersions, this.targetBranch]; + }, + selectedVersionName() { + const selectedVersion = this.startVersion || this.targetBranch || this.mergeRequestVersion; + return this.versionName(selectedVersion); + }, + }, + methods: { + commitsText(version) { + return n__( + `${version.commitsCount} commit,`, + `${version.commitsCount} commits,`, + version.commitsCount, + ); + }, + href(version) { + if (this.showCommitCount) { + return version.versionPath; + } + return version.comparePath; + }, + versionName(version) { + if (this.isLatest(version)) { + return __('latest version'); + } + if (this.targetBranch && (this.isBase(version) || !version)) { + return this.targetBranch.branchName; + } + return `version ${version.versionIndex}`; + }, + isActive(version) { + if (!version) { + return false; + } + + if (this.targetBranch) { + return ( + (this.isBase(version) && !this.startVersion) || + (this.startVersion && this.startVersion.versionIndex === version.versionIndex) + ); + } + + return version.versionIndex === this.mergeRequestVersion.versionIndex; + }, + isBase(version) { + if (!version || !this.targetBranch) { + return false; + } + return version.versionIndex === -1; + }, + isLatest(version) { + return ( + this.mergeRequestVersion && version.versionIndex === this.targetVersions[0].versionIndex + ); + }, + }, +}; +</script> + +<template> + <span class="dropdown inline"> + <a + class="dropdown-toggle btn btn-default" + data-toggle="dropdown" + aria-expanded="false" + > + <span> + {{ selectedVersionName }} + </span> + <Icon + :size="12" + name="angle-down" + /> + </a> + <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> + <div class="dropdown-content"> + <ul> + <li + v-for="version in targetVersions" + :key="version.id" + > + <a + :class="{ 'is-active': isActive(version) }" + :href="href(version)" + > + <div> + <strong> + {{ versionName(version) }} + <template v-if="isBase(version)"> + (base) + </template> + </strong> + </div> + <div> + <small class="commit-sha"> + {{ version.truncatedCommitSha }} + </small> + </div> + <div> + <small> + <template v-if="showCommitCount"> + {{ commitsText(version) }} + </template> + <time-ago + v-if="version.createdAt" + :time="version.createdAt" + class="js-timeago js-timeago-render" + /> + </small> + </div> + </a> + </li> + </ul> + </div> + </div> + </span> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue new file mode 100644 index 00000000000..adcd22f7876 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -0,0 +1,38 @@ +<script> +import { mapGetters } from 'vuex'; +import InlineDiffView from './inline_diff_view.vue'; +import ParallelDiffView from './parallel_diff_view.vue'; + +export default { + components: { + InlineDiffView, + ParallelDiffView, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + }, + computed: { + ...mapGetters(['isInlineView', 'isParallelView']), + }, +}; +</script> + +<template> + <div class="diff-content"> + <div class="diff-viewer"> + <inline-diff-view + v-if="isInlineView" + :diff-file="diffFile" + :diff-lines="diffFile.highlightedDiffLines || []" + /> + <parallel-diff-view + v-if="isParallelView" + :diff-file="diffFile" + :diff-lines="diffFile.parallelDiffLines || []" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue new file mode 100644 index 00000000000..39d535036f6 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -0,0 +1,39 @@ +<script> +import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; + +export default { + components: { + noteableDiscussion, + }, + props: { + discussions: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <div + v-if="discussions.length" + > + <div + v-for="discussion in discussions" + :key="discussion.id" + class="discussion-notes diff-discussions" + > + <ul + :data-discussion-id="discussion.id" + class="notes" + > + <noteable-discussion + :discussion="discussion" + :render-header="false" + :render-diff-file="false" + :always-expanded="true" + /> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue new file mode 100644 index 00000000000..108eefdac5f --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -0,0 +1,191 @@ +<script> +import { mapActions } from 'vuex'; +import _ from 'underscore'; +import { __, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import DiffFileHeader from './diff_file_header.vue'; +import DiffContent from './diff_content.vue'; + +export default { + components: { + DiffFileHeader, + DiffContent, + LoadingIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + isActive: false, + isLoadingCollapsedDiff: false, + forkMessageVisible: false, + }; + }, + computed: { + isDiscussionsExpanded() { + return true; // TODO: @fatihacet - Fix this. + }, + isCollapsed() { + return this.file.collapsed || false; + }, + viewBlobLink() { + return sprintf( + __('You can %{linkStart}view the blob%{linkEnd} instead.'), + { + linkStart: `<a href="${_.escape(this.file.viewPath)}">`, + linkEnd: '</a>', + }, + false, + ); + }, + }, + mounted() { + document.addEventListener('scroll', this.handleScroll); + }, + beforeDestroy() { + document.removeEventListener('scroll', this.handleScroll); + }, + methods: { + ...mapActions(['loadCollapsedDiff']), + handleToggle() { + const { collapsed, highlightedDiffLines, parallelDiffLines } = this.file; + + if (collapsed && !highlightedDiffLines && !parallelDiffLines.length) { + this.handleLoadCollapsedDiff(); + } else { + this.file.collapsed = !this.file.collapsed; + } + }, + handleScroll() { + if (!this.updating) { + requestAnimationFrame(this.scrollUpdate.bind(this)); + this.updating = true; + } + }, + scrollUpdate() { + const header = document.querySelector('.js-diff-files-changed'); + if (!header) { + this.updating = false; + return; + } + + const { top, bottom } = this.$el.getBoundingClientRect(); + const { top: topOfFixedHeader, bottom: bottomOfFixedHeader } = header.getBoundingClientRect(); + + const headerOverlapsContent = top < topOfFixedHeader && bottom > bottomOfFixedHeader; + const fullyAboveHeader = bottom < bottomOfFixedHeader; + const fullyBelowHeader = top > topOfFixedHeader; + + if (headerOverlapsContent && !this.isActive) { + this.$emit('setActive'); + this.isActive = true; + } else if (this.isActive && (fullyAboveHeader || fullyBelowHeader)) { + this.$emit('unsetActive'); + this.isActive = false; + } + + this.updating = false; + }, + handleLoadCollapsedDiff() { + this.isLoadingCollapsedDiff = true; + + this.loadCollapsedDiff(this.file) + .then(() => { + this.isLoadingCollapsedDiff = false; + this.file.collapsed = false; + }) + .catch(() => { + this.isLoadingCollapsedDiff = false; + createFlash(__('Something went wrong on our end. Please try again!')); + }); + }, + showForkMessage() { + this.forkMessageVisible = true; + }, + hideForkMessage() { + this.forkMessageVisible = false; + }, + }, +}; +</script> + +<template> + <div + :id="file.fileHash" + class="diff-file file-holder" + > + <diff-file-header + :current-user="currentUser" + :diff-file="file" + :collapsible="true" + :expanded="!isCollapsed" + :discussions-expanded="isDiscussionsExpanded" + :add-merge-request-buttons="true" + class="js-file-title file-title" + @toggleFile="handleToggle" + @showForkMessage="showForkMessage" + /> + + <div + v-if="forkMessageVisible" + class="js-file-fork-suggestion-section file-fork-suggestion"> + <span class="file-fork-suggestion-note"> + You're not allowed to <span class="js-file-fork-suggestion-section-action">edit</span> + files in this project directly. Please fork this project, + make your changes there, and submit a merge request. + </span> + <a + :href="file.forkPath" + class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" + > + Fork + </a> + <button + class="js-cancel-fork-suggestion-button btn btn-grouped" + type="button" + @click="hideForkMessage" + > + Cancel + </button> + </div> + + <diff-content + v-show="!isCollapsed" + :class="{ hidden: isCollapsed || file.tooLarge }" + :diff-file="file" + /> + <loading-icon + v-if="isLoadingCollapsedDiff" + class="diff-content loading" + /> + <div + v-show="isCollapsed && !isLoadingCollapsedDiff && !file.tooLarge" + class="nothing-here-block diff-collapsed" + > + {{ __('This diff is collapsed.') }} + <a + class="click-to-expand js-click-to-expand" + href="#" + @click.prevent="handleToggle" + > + {{ __('Click to expand it.') }} + </a> + </div> + <div + v-if="file.tooLarge" + class="nothing-here-block diff-collapsed js-too-large-diff" + > + {{ __('This source diff could not be displayed because it is too large.') }} + <span v-html="viewBlobLink"></span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue new file mode 100644 index 00000000000..6bad389f778 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -0,0 +1,254 @@ +<script> +import _ from 'underscore'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import Tooltip from '~/vue_shared/directives/tooltip'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { __, s__, sprintf } from '~/locale'; +import EditButton from './edit_button.vue'; + +export default { + components: { + ClipboardButton, + EditButton, + Icon, + }, + directives: { + Tooltip, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + collapsible: { + type: Boolean, + required: false, + default: false, + }, + addMergeRequestButtons: { + type: Boolean, + required: false, + default: false, + }, + expanded: { + type: Boolean, + required: false, + default: true, + }, + discussionsExpanded: { + type: Boolean, + required: false, + default: true, + }, + currentUser: { + type: Object, + required: true, + }, + }, + data() { + return { + blobForkSuggestion: null, + }; + }, + computed: { + icon() { + if (this.diffFile.submodule) { + return 'archive'; + } + + return this.diffFile.blob.icon; + }, + titleLink() { + if (this.diffFile.submodule) { + return this.diffFile.submoduleTreeUrl || this.diffFile.submoduleLink; + } + + return `#${this.diffFile.fileHash}`; + }, + filePath() { + if (this.diffFile.submodule) { + return `${this.diffFile.filePath} @ ${truncateSha(this.diffFile.blob.id)}`; + } + + if (this.diffFile.deletedFile) { + return sprintf(__('%{filePath} deleted'), { filePath: this.diffFile.filePath }, false); + } + + return this.diffFile.filePath; + }, + titleTag() { + return this.diffFile.fileHash ? 'a' : 'span'; + }, + isUsingLfs() { + return this.diffFile.storedExternally && this.diffFile.externalStorage === 'lfs'; + }, + collapseIcon() { + return this.expanded ? 'chevron-down' : 'chevron-right'; + }, + isDiscussionsExpanded() { + return this.discussionsExpanded && this.expanded; + }, + viewFileButtonText() { + const truncatedContentSha = _.escape(truncateSha(this.diffFile.contentSha)); + return sprintf( + s__('MergeRequests|View file @ %{commitId}'), + { + commitId: `<span class="commit-sha">${truncatedContentSha}</span>`, + }, + false, + ); + }, + viewReplacedFileButtonText() { + const truncatedBaseSha = _.escape(truncateSha(this.diffFile.diffRefs.baseSha)); + return sprintf( + s__('MergeRequests|View replaced file @ %{commitId}'), + { + commitId: `<span class="commit-sha">${truncatedBaseSha}</span>`, + }, + false, + ); + }, + }, + methods: { + handleToggle(e, checkTarget) { + if (!checkTarget || e.target === this.$refs.header) { + this.$emit('toggleFile'); + } + }, + showForkMessage() { + this.$emit('showForkMessage'); + }, + }, +}; +</script> + +<template> + <div + ref="header" + class="js-file-title file-title file-title-flex-parent" + @click="handleToggle($event, true)" + > + <div class="file-header-content"> + <icon + v-if="collapsible" + :name="collapseIcon" + :size="16" + aria-hidden="true" + class="diff-toggle-caret" + @click.stop="handleToggle" + /> + <a + ref="titleWrapper" + :href="titleLink" + > + <i + :class="`fa-${icon}`" + class="fa fa-fw" + aria-hidden="true" + ></i> + <span v-if="diffFile.renamedFile"> + <strong + v-tooltip + :title="diffFile.oldPath" + class="file-title-name" + data-container="body" + > + {{ diffFile.oldPath }} + </strong> + → + <strong + v-tooltip + :title="diffFile.newPath" + class="file-title-name" + data-container="body" + > + {{ diffFile.newPath }} + </strong> + </span> + + <strong + v-tooltip + v-else + :title="filePath" + class="file-title-name" + data-container="body" + > + {{ filePath }} + </strong> + </a> + + <clipboard-button + :title="__('Copy file path to clipboard')" + :text="diffFile.filePath" + css-class="btn-default btn-transparent btn-clipboard" + /> + + <small + v-if="diffFile.modeChanged" + ref="fileMode" + > + {{ diffFile.aMode }} → {{ diffFile.bMode }} + </small> + + <span + v-if="isUsingLfs" + class="label label-lfs append-right-5" + > + {{ __('LFS') }} + </span> + </div> + + <div + v-if="!diffFile.submodule && addMergeRequestButtons" + class="file-actions d-none d-md-block" + > + <template + v-if="diffFile.blob && diffFile.blob.readableText" + > + <button + :class="{ active: isDiscussionsExpanded }" + :title="s__('MergeRequests|Toggle comments for this file')" + class="btn js-toggle-diff-comments" + type="button" + > + <icon name="comment" /> + </button> + + <edit-button + v-if="!diffFile.deletedFile" + :current-user="currentUser" + :edit-path="diffFile.editPath" + :can-modify-blob="diffFile.canModifyBlob" + @showForkMessage="showForkMessage" + /> + </template> + + <a + v-if="diffFile.replacedViewPath" + :href="diffFile.replacedViewPath" + class="btn view-file js-view-file" + v-html="viewReplacedFileButtonText" + > + </a> + <a + :href="diffFile.viewPath" + class="btn view-file js-view-file" + v-html="viewFileButtonText" + > + </a> + + <a + v-tooltip + v-if="diffFile.externalUrl" + :href="diffFile.externalUrl" + :title="`View on ${diffFile.formattedExternalUrl}`" + target="_blank" + rel="noopener noreferrer" + class="btn btn-file-option" + > + <icon name="external-link" /> + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue new file mode 100644 index 00000000000..3193b18becb --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue @@ -0,0 +1,105 @@ +<script> +import { mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import { pluralize, truncate } from '~/lib/utils/text_utility'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants'; + +export default { + directives: { + tooltip, + }, + components: { + Icon, + UserAvatarImage, + }, + props: { + discussions: { + type: Array, + required: true, + }, + }, + computed: { + discussionsExpanded() { + return this.discussions.every(discussion => discussion.expanded); + }, + allDiscussions() { + return this.discussions.reduce((acc, note) => acc.concat(note.notes), []); + }, + notesInGutter() { + return this.allDiscussions.slice(0, COUNT_OF_AVATARS_IN_GUTTER).map(n => ({ + note: n.note, + author: n.author, + })); + }, + moreCount() { + return this.allDiscussions.length - this.notesInGutter.length; + }, + moreText() { + if (this.moreCount === 0) { + return ''; + } + + return pluralize(`${this.moreCount} more comment`, this.moreCount); + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + getTooltipText(noteData) { + let note = noteData.note; + + if (note.length > LENGTH_OF_AVATAR_TOOLTIP) { + note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP); + } + + return `${noteData.author.name}: ${note}`; + }, + toggleDiscussions() { + this.discussions.forEach(discussion => { + this.toggleDiscussion({ + discussionId: discussion.id, + }); + }); + }, + }, +}; +</script> + +<template> + <div class="diff-comment-avatar-holders"> + <button + v-if="discussionsExpanded" + type="button" + aria-label="Show comments" + class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button" + @click="toggleDiscussions" + > + <icon + :size="12" + name="collapse" + /> + </button> + <template v-else> + <user-avatar-image + v-for="note in notesInGutter" + :key="note.id" + :img-src="note.author.avatar_url" + :tooltip-text="getTooltipText(note)" + :size="19" + class="diff-comment-avatar js-diff-comment-avatar" + @click.native="toggleDiscussions" + /> + <span + v-tooltip + v-if="moreText" + :title="moreText" + class="diff-comments-more-count has-tooltip js-diff-comment-avatar js-diff-comment-plus" + data-container="body" + data-placement="top" + role="button" + @click="toggleDiscussions" + >+{{ moreCount }}</span> + </template> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue new file mode 100644 index 00000000000..05dca0cdd9a --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue @@ -0,0 +1,203 @@ +<script> +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_POSITION_RIGHT, + UNFOLD_COUNT, +} from '../constants'; +import * as utils from '../store/utils'; + +export default { + components: { + DiffGutterAvatars, + Icon, + }, + props: { + fileHash: { + type: String, + required: true, + }, + contextLinesPath: { + type: String, + required: true, + }, + lineType: { + type: String, + required: false, + default: '', + }, + lineNumber: { + type: Number, + required: false, + default: 0, + }, + lineCode: { + type: String, + required: false, + default: '', + }, + linePosition: { + type: String, + required: false, + default: '', + }, + metaData: { + type: Object, + required: false, + default: () => ({}), + }, + showCommentButton: { + type: Boolean, + required: false, + default: false, + }, + isBottom: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapState({ + diffViewType: state => state.diffs.diffViewType, + diffFiles: state => state.diffs.diffFiles, + }), + ...mapGetters(['isLoggedIn', 'discussionsByLineCode']), + isMatchLine() { + return this.lineType === MATCH_LINE_TYPE; + }, + isContextLine() { + return this.lineType === CONTEXT_LINE_TYPE; + }, + isMetaLine() { + return this.lineType === OLD_NO_NEW_LINE_TYPE || this.lineType === NEW_NO_NEW_LINE_TYPE; + }, + lineHref() { + return this.lineCode ? `#${this.lineCode}` : '#'; + }, + shouldShowCommentButton() { + return ( + this.isLoggedIn && + this.showCommentButton && + !this.isMatchLine && + !this.isContextLine && + !this.hasDiscussions && + !this.isMetaLine + ); + }, + discussions() { + return this.discussionsByLineCode[this.lineCode] || []; + }, + hasDiscussions() { + return this.discussions.length > 0; + }, + shouldShowAvatarsOnGutter() { + let render = this.hasDiscussions && this.showCommentButton; + + if (!this.lineType && this.linePosition === LINE_POSITION_RIGHT) { + render = false; + } + + return render; + }, + }, + methods: { + ...mapActions(['loadMoreLines']), + handleCommentButton() { + this.$emit('showCommentForm', { lineCode: this.lineCode }); + }, + handleLoadMoreLines() { + if (this.isRequesting) { + return; + } + + this.isRequesting = true; + const endpoint = this.contextLinesPath; + const oldLineNumber = this.metaData.oldPos || 0; + const newLineNumber = this.metaData.newPos || 0; + const offset = newLineNumber - oldLineNumber; + const bottom = this.isBottom; + const fileHash = this.fileHash; + const view = this.diffViewType; + let unfold = true; + let lineNumber = newLineNumber - 1; + let since = lineNumber - UNFOLD_COUNT; + let to = lineNumber; + + if (bottom) { + lineNumber = newLineNumber + 1; + since = lineNumber; + to = lineNumber + UNFOLD_COUNT; + } else { + const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); + const indexForInline = utils.findIndexInInlineLines(diffFile.highlightedDiffLines, { + oldLineNumber, + newLineNumber, + }); + const prevLine = diffFile.highlightedDiffLines[indexForInline - 2]; + const prevLineNumber = (prevLine && prevLine.newLine) || 0; + + if (since <= prevLineNumber + 1) { + since = prevLineNumber + 1; + unfold = false; + } + } + + const params = { since, to, bottom, offset, unfold, view }; + const lineNumbers = { oldLineNumber, newLineNumber }; + this.loadMoreLines({ endpoint, params, lineNumbers, fileHash }) + .then(() => { + this.isRequesting = false; + }) + .catch(() => { + createFlash(s__('Diffs|Something went wrong while fetching diff lines.')); + this.isRequesting = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <span + v-if="isMatchLine" + class="context-cell" + role="button" + @click="handleLoadMoreLines" + >...</span> + <template + v-else + > + <button + v-show="shouldShowCommentButton" + type="button" + class="add-diff-note js-add-diff-note-button" + title="Add a comment to this line" + @click="handleCommentButton" + > + <icon + :size="12" + name="comment" + /> + </button> + <a + v-if="lineNumber" + :data-linenumber="lineNumber" + :href="lineHref" + > + </a> + <diff-gutter-avatars + v-if="shouldShowAvatarsOnGutter" + :discussions="discussions" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue new file mode 100644 index 00000000000..86f5e98194d --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -0,0 +1,93 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import noteForm from '../../notes/components/note_form.vue'; +import { getNoteFormData } from '../store/utils'; + +export default { + components: { + noteForm, + }, + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + line: { + type: Object, + required: true, + }, + position: { + type: String, + required: false, + default: '', + }, + noteTargetLine: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState({ + noteableData: state => state.notes.noteableData, + diffViewType: state => state.diffs.diffViewType, + }), + ...mapGetters(['noteableType', 'getNotesDataByProp']), + }, + methods: { + ...mapActions(['cancelCommentForm', 'saveNote', 'fetchDiscussions']), + handleCancelCommentForm() { + this.cancelCommentForm({ + lineCode: this.line.lineCode, + }); + }, + handleSaveNote(note) { + const postData = getNoteFormData({ + note, + noteableData: this.noteableData, + noteableType: this.noteableType, + noteTargetLine: this.noteTargetLine, + diffViewType: this.diffViewType, + diffFile: this.diffFile, + linePosition: this.position, + }); + + this.saveNote(postData) + .then(() => { + const endpoint = this.getNotesDataByProp('discussionsPath'); + + this.fetchDiscussions(endpoint) + .then(() => { + this.handleCancelCommentForm(); + }) + .catch(() => { + createFlash(s__('MergeRequests|Updating discussions failed')); + }); + }) + .catch(() => { + createFlash(s__('MergeRequests|Saving the comment failed')); + }); + }, + }, +}; +</script> + +<template> + <div + class="content discussion-form discussion-form-container discussion-notes" + > + <note-form + :is-editing="true" + :line-code="line.lineCode" + save-button-title="Comment" + class="diff-comment-form" + @cancelForm="handleCancelCommentForm" + @handleFormUpdate="handleSaveNote" + /> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue new file mode 100644 index 00000000000..ebf90631d76 --- /dev/null +++ b/app/assets/javascripts/diffs/components/edit_button.vue @@ -0,0 +1,42 @@ +<script> +export default { + props: { + editPath: { + type: String, + required: true, + }, + currentUser: { + type: Object, + required: true, + }, + canModifyBlob: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + handleEditClick(evt) { + if (!this.currentUser || this.canModifyBlob) { + // if we can Edit, do default Edit button behavior + return; + } + + if (this.currentUser.canFork && this.currentUser.canCreateMergeRequest) { + evt.preventDefault(); + this.$emit('showForkMessage'); + } + }, + }, +}; +</script> + +<template> + <a + :href="editPath" + class="btn btn-default js-edit-blob" + @click="handleEditClick" + > + Edit + </a> +</template> diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue new file mode 100644 index 00000000000..017dcfcc357 --- /dev/null +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -0,0 +1,51 @@ +<script> +export default { + props: { + total: { + type: String, + required: true, + }, + visible: { + type: Number, + required: true, + }, + plainDiffPath: { + type: String, + required: true, + }, + emailPatchPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="alert alert-warning"> + <h4> + {{ __('Too many changes to show.') }} + <div class="pull-right"> + <a + :href="plainDiffPath" + class="btn btn-sm" + > + {{ __('Plain diff') }} + </a> + <a + :href="emailPatchPath" + class="btn btn-sm" + > + {{ __('Email patch') }} + </a> + </div> + </h4> + <p> + To preserve performance only + <strong> + {{ visible }} of {{ total }} + </strong> + files are displayed. + </p> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue new file mode 100644 index 00000000000..0ed3dc7f3ad --- /dev/null +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -0,0 +1,117 @@ +<script> +import diffContentMixin from '../mixins/diff_content'; +import { + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + LINE_UNFOLD_CLASS_NAME, +} from '../constants'; + +export default { + mixins: [diffContentMixin], + methods: { + handleMouse(lineCode, isOver) { + this.hoveredLineCode = isOver ? lineCode : null; + }, + getLineClass(line) { + const isSameLine = this.hoveredLineCode && this.hoveredLineCode === line.lineCode; + const isMatchLine = line.type === MATCH_LINE_TYPE; + const isContextLine = line.type === CONTEXT_LINE_TYPE; + const isMetaLine = line.type === OLD_NO_NEW_LINE_TYPE || line.type === NEW_NO_NEW_LINE_TYPE; + + return { + [line.type]: line.type, + [LINE_UNFOLD_CLASS_NAME]: isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && isSameLine && !isMatchLine && !isContextLine && !isMetaLine, + }; + }, + }, +}; +</script> + +<template> + <table + :class="userColorScheme" + :data-commit-id="commitId" + class="code diff-wrap-lines js-syntax-highlight text-file"> + <tbody> + <template + v-for="(line, index) in normalizedDiffLines" + > + <tr + :id="line.lineCode || `${fileHash}_${line.oldLine}_${line.newLine}`" + :key="line.lineCode" + :class="getRowClass(line)" + class="line_holder" + @mouseover="handleMouse(line.lineCode, true)" + @mouseout="handleMouse(line.lineCode, false)" + > + <td + :class="getLineClass(line)" + class="diff-line-num old_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.type" + :line-code="line.lineCode" + :line-number="line.oldLine" + :meta-data="line.metaData" + :show-comment-button="true" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="index + 1 === diffLinesLength" + @showCommentForm="handleShowCommentForm" + /> + </td> + <td + :class="getLineClass(line)" + class="diff-line-num new_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.type" + :line-code="line.lineCode" + :line-number="line.newLine" + :meta-data="line.metaData" + :is-bottom="index + 1 === diffLinesLength" + :context-lines-path="diffFile.contextLinesPath" + /> + </td> + <td + :class="line.type" + class="line_content" + v-html="line.richText" + > + </td> + </tr> + <tr + v-if="isDiscussionExpanded(line.lineCode) || diffLineCommentForms[line.lineCode]" + :key="index" + :class="discussionsByLineCode[line.lineCode] ? '' : 'js-temp-notes-holder'" + class="notes_holder" + > + <td + class="notes_line" + colspan="2" + ></td> + <td class="notes_content"> + <div class="content"> + <diff-discussions + :discussions="discussionsByLineCode[line.lineCode] || []" + /> + <diff-line-note-form + v-if="diffLineCommentForms[line.lineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line" + :note-target-line="diffLines[index]" + /> + </div> + </td> + </tr> + </template> + </tbody> + </table> +</template> diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue new file mode 100644 index 00000000000..d817157fbcd --- /dev/null +++ b/app/assets/javascripts/diffs/components/no_changes.vue @@ -0,0 +1,49 @@ +<script> +import { mapState } from 'vuex'; +import emptyImage from '~/../../views/shared/icons/_mr_widget_empty_state.svg'; + +export default { + data() { + return { + emptyImage, + }; + }, + computed: { + ...mapState({ + sourceBranch: state => state.notes.noteableData.source_branch, + targetBranch: state => state.notes.noteableData.target_branch, + newBlobPath: state => state.notes.noteableData.new_blob_path, + }), + }, +}; +</script> + +<template> + <div + class="row empty-state nothing-here-block" + > + <div class="col-xs-12"> + <div class="svg-content"> + <span + v-html="emptyImage" + ></span> + </div> + </div> + <div class="col-xs-12"> + <div class="text-content text-center"> + No changes between + <span class="ref-name">{{ sourceBranch }}</span> + and + <span class="ref-name">{{ targetBranch }}</span> + <div class="text-center"> + <a + :href="newBlobPath" + class="btn btn-save" + > + {{ __('Create commit') }} + </a> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue new file mode 100644 index 00000000000..2ddf8e6c6ed --- /dev/null +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -0,0 +1,224 @@ +<script> +import diffContentMixin from '../mixins/diff_content'; +import { + EMPTY_CELL_TYPE, + MATCH_LINE_TYPE, + CONTEXT_LINE_TYPE, + OLD_NO_NEW_LINE_TYPE, + NEW_NO_NEW_LINE_TYPE, + LINE_HOVER_CLASS_NAME, + LINE_UNFOLD_CLASS_NAME, + LINE_POSITION_RIGHT, +} from '../constants'; + +export default { + mixins: [diffContentMixin], + computed: { + parallelDiffLines() { + return this.normalizedDiffLines.map(line => { + if (!line.left) { + Object.assign(line, { left: { type: EMPTY_CELL_TYPE } }); + } else if (!line.right) { + Object.assign(line, { right: { type: EMPTY_CELL_TYPE } }); + } + + return line; + }); + }, + }, + methods: { + hasDiscussion(line) { + const discussions = this.discussionsByLineCode; + const hasDiscussion = discussions[line.left.lineCode] || discussions[line.right.lineCode]; + + return hasDiscussion; + }, + getClassName(line, position) { + const { type, lineCode } = line[position]; + const isMatchLine = type === MATCH_LINE_TYPE; + const isContextLine = !isMatchLine && type !== EMPTY_CELL_TYPE && type !== CONTEXT_LINE_TYPE; + const isMetaLine = type === OLD_NO_NEW_LINE_TYPE || type === NEW_NO_NEW_LINE_TYPE; + const isSameLine = this.hoveredLineCode && this.hoveredLineCode === lineCode; + const isSameSection = position === this.hoveredSection; + + return { + [type]: type, + [LINE_UNFOLD_CLASS_NAME]: isMatchLine, + [LINE_HOVER_CLASS_NAME]: + this.isLoggedIn && isContextLine && isSameLine && isSameSection && !isMetaLine, + }; + }, + handleMouse(e, line, isHover) { + if (isHover) { + const cell = e.target.closest('td'); + + if (this.$refs.leftLines.indexOf(cell) > -1) { + this.hoveredLineCode = line.left.lineCode; + this.hoveredSection = 'left'; + } else if (this.$refs.rightLines.indexOf(cell) > -1) { + this.hoveredLineCode = line.right.lineCode; + this.hoveredSection = 'right'; + } + } else { + this.hoveredLineCode = null; + this.hoveredSection = null; + } + }, + shouldRenderDiscussionsRow(line) { + const hasDiscussion = this.hasDiscussion(line) && this.hasAnyExpandedDiscussion(line); + const hasCommentFormOnLeft = this.diffLineCommentForms[line.left.lineCode]; + const hasCommentFormOnRight = this.diffLineCommentForms[line.right.lineCode]; + + return hasDiscussion || hasCommentFormOnLeft || hasCommentFormOnRight; + }, + shouldRenderDiscussions(line, position) { + const { lineCode } = line[position]; + let render = this.discussionsByLineCode[lineCode] && this.isDiscussionExpanded(lineCode); + + // Avoid rendering context line discussions on the right side in parallel view + if (position === LINE_POSITION_RIGHT) { + render = render && line.right.type; + } + + return render; + }, + hasAnyExpandedDiscussion(line) { + const isLeftExpanded = this.isDiscussionExpanded(line.left.lineCode); + const isRightExpanded = this.isDiscussionExpanded(line.right.lineCode); + + return isLeftExpanded || isRightExpanded; + }, + getLineCode(line, side) { + const lineCode = side.lineCode; + if (lineCode) { + return lineCode; + } + + return `${this.fileHash}_${line.left.oldLine}_${line.right.newLine}`; + }, + }, +}; +</script> + +<template> + <div + :class="userColorScheme" + :data-commit-id="commitId" + class="code diff-wrap-lines js-syntax-highlight text-file"> + <table> + <tbody> + <template + v-for="(line, index) in parallelDiffLines" + > + <tr + :key="index" + :class="getRowClass(line)" + class="line_holder parallel" + @mouseover="handleMouse($event, line, true)" + @mouseout="handleMouse($event, line, false)" + > + <td + ref="leftLines" + :class="getClassName(line, 'left')" + class="diff-line-num old_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.left.type" + :line-code="line.left.lineCode" + :line-number="line.left.oldLine" + :meta-data="line.left.metaData" + :show-comment-button="true" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="index + 1 === diffLinesLength" + line-position="left" + @showCommentForm="handleShowCommentForm" + /> + </td> + <td + ref="leftLines" + :class="getClassName(line, 'left')" + :id="getLineCode(line, line.left)" + class="line_content parallel left-side" + v-html="line.left.richText" + > + </td> + <td + ref="rightLines" + :class="getClassName(line, 'right')" + class="diff-line-num new_line" + > + <diff-line-gutter-content + :file-hash="fileHash" + :line-type="line.right.type" + :line-code="line.right.lineCode" + :line-number="line.right.newLine" + :meta-data="line.right.metaData" + :show-comment-button="true" + :context-lines-path="diffFile.contextLinesPath" + :is-bottom="index + 1 === diffLinesLength" + line-position="right" + @showCommentForm="handleShowCommentForm" + /> + </td> + <td + ref="rightLines" + :class="getClassName(line, 'right')" + :id="getLineCode(line, line.right)" + class="line_content parallel right-side" + v-html="line.right.richText" + > + </td> + </tr> + <tr + v-if="shouldRenderDiscussionsRow(line)" + :key="line.left.lineCode || line.right.lineCode" + :class="hasDiscussion(line) ? '' : 'js-temp-notes-holder'" + class="notes_holder" + > + <td class="notes_line old"></td> + <td class="notes_content parallel old"> + <div + v-if="shouldRenderDiscussions(line, 'left')" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[line.left.lineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[line.left.lineCode] && + diffLineCommentForms[line.left.lineCode]" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.left" + :note-target-line="diffLines[index].left" + position="left" + /> + </td> + <td class="notes_line new"></td> + <td class="notes_content parallel new"> + <div + v-if="shouldRenderDiscussions(line, 'right')" + class="content" + > + <diff-discussions + :discussions="discussionsByLineCode[line.right.lineCode]" + /> + </div> + <diff-line-note-form + v-if="diffLineCommentForms[line.right.lineCode] && + diffLineCommentForms[line.right.lineCode] && line.right.type" + :diff-file="diffFile" + :diff-lines="diffLines" + :line="line.right" + :note-target-line="diffLines[index].right" + position="right" + /> + </td> + </tr> + </template> + </tbody> + </table> + </div> +</template> |