Commit 2118f567 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'ph/diffsVirtualScrolling' into 'master'

Added virtual scrolling to diffs [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!60312
parents 2280f026 a9ac65c8
......@@ -3,6 +3,7 @@ import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Mousetrap from 'mousetrap';
import { mapState, mapGetters, mapActions } from 'vuex';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import api from '~/api';
import {
keysFor,
......@@ -17,7 +18,6 @@ import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '../../notes/event_hub';
import {
......@@ -69,8 +69,9 @@ export default {
PanelResizer,
GlPagination,
GlSprintf,
DynamicScroller,
DynamicScrollerItem,
},
mixins: [glFeatureFlagsMixin()],
alerts: {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
......@@ -196,7 +197,12 @@ export default {
'renderTreeList',
'showWhitespace',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters('diffs', [
'whichCollapsedTypes',
'isParallelView',
'currentDiffIndex',
'isVirtualScrollingEnabled',
]),
...mapGetters('batchComments', ['draftsCount']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
......@@ -561,17 +567,41 @@ export default {
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
<template v-else-if="renderDiffFiles">
<diff-file
v-for="(file, index) in diffs"
:key="file.newPath"
:file="file"
:reviewed="fileReviews[file.id]"
:is-first-file="index === 0"
:is-last-file="index === diffFilesLength - 1"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
/>
<dynamic-scroller
v-if="isVirtualScrollingEnabled"
:items="diffs"
:min-item-size="70"
:buffer="1000"
:use-transform="false"
page-mode
>
<template #default="{ item, index, active }">
<dynamic-scroller-item :item="item" :active="active">
<diff-file
:file="item"
:reviewed="fileReviews[item.id]"
:is-first-file="index === 0"
:is-last-file="index === diffFilesLength - 1"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
/>
</dynamic-scroller-item>
</template>
</dynamic-scroller>
<template v-else>
<diff-file
v-for="(file, index) in diffs"
:key="file.new_path"
:file="file"
:reviewed="fileReviews[file.id]"
:is-first-file="index === 0"
:is-last-file="index === diffFilesLength - 1"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
/>
</template>
<div
v-if="showFileByFileNavigation"
data-testid="file-by-file-navigation"
......
......@@ -49,9 +49,7 @@ export default {
},
},
computed: {
...mapState({
projectPath: (state) => state.diffs.projectPath,
}),
...mapState('diffs', ['projectPath']),
...mapGetters('diffs', [
'isInlineView',
'isParallelView',
......
......@@ -83,7 +83,7 @@ export default {
computed: {
...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']),
...mapGetters(['isNotesFetched']),
...mapGetters('diffs', ['getDiffFileDiscussions']),
...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']),
viewBlobHref() {
return escape(this.file.view_path);
},
......@@ -290,6 +290,7 @@ export default {
'is-active': currentDiffFileId === file.file_hash,
'comments-disabled': Boolean(file.brokenSymlink),
'has-body': showBody,
'is-virtual-scrolling': isVirtualScrollingEnabled,
}"
:data-path="file.new_path"
class="diff-file file-holder gl-border-none"
......
......@@ -170,3 +170,6 @@ export function suggestionCommitMessage(state, _, rootState) {
},
});
}
export const isVirtualScrollingEnabled = (state) =>
!state.viewDiffsFileByFile && window.gon?.features?.diffsVirtualScrolling;
......@@ -729,7 +729,7 @@ table.code {
}
.files {
.diff-file:last-child {
.diff-file:not(.is-virtual-scrolling):last-child {
margin-bottom: 0;
}
}
......
......@@ -7,6 +7,10 @@
.diff-files-holder {
flex: 1;
min-width: 0;
.vue-recycle-scroller__item-wrapper {
overflow: visible;
}
}
.with-system-header {
......
......@@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
......
---
name: diffs_virtual_scrolling
introduced_by_url:
rollout_issue_url:
milestone: '13.12'
type: development
group: group::code review
default_enabled: false
......@@ -268,6 +268,7 @@ RSpec.configure do |config|
stub_feature_flags(file_identifier_hash: false)
stub_feature_flags(unified_diff_components: false)
stub_feature_flags(diffs_virtual_scrolling: false)
# The following `vue_issues_list`/`vue_issuables_list` stubs can be removed
# once the Vue issues page has feature parity with the current Haml page
......
{
"name": "vue-virtual-scroller",
"description": "Smooth scrolling for any amount of data",
"version": "1.0.10",
"author": {
"name": "Guillaume Chau",
"email": "guillaume.b.chau@gmail.com"
},
"keywords": [
"vue",
"vuejs",
"plugin"
],
"license": "MIT",
"main": "src/index.js",
"scripts": {},
"repository": {
"type": "git",
"url": "git+https://github.com/Akryum/vue-virtual-scroller.git"
},
"bugs": {
"url": "https://github.com/Akryum/vue-virtual-scroller/issues"
},
"homepage": "https://github.com/Akryum/vue-virtual-scroller#readme",
"dependencies": {
"scrollparent": "^2.0.1",
"vue-observe-visibility": "^0.4.4",
"vue-resize": "^0.4.5"
},
"peerDependencies": {
"vue": "^2.6.11"
},
"devDependencies": {
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
}
<template>
<RecycleScroller
ref="scroller"
:items="itemsWithSize"
:min-item-size="minItemSize"
:direction="direction"
key-field="id"
v-bind="$attrs"
@resize="onScrollerResize"
@visible="onScrollerVisible"
v-on="listeners"
>
<template slot-scope="{ item: itemWithSize, index, active }">
<slot
v-bind="{
item: itemWithSize.item,
index,
active,
itemWithSize
}"
/>
</template>
<template slot="before">
<slot name="before" />
</template>
<template slot="after">
<slot name="after" />
</template>
</RecycleScroller>
</template>
<script>
import RecycleScroller from './RecycleScroller.vue'
import { props, simpleArray } from './common'
export default {
name: 'DynamicScroller',
components: {
RecycleScroller,
},
inheritAttrs: false,
provide () {
if (typeof ResizeObserver !== 'undefined') {
this.$_resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (entry.target) {
const event = new CustomEvent(
'resize',
{
detail: {
contentRect: entry.contentRect,
},
},
)
entry.target.dispatchEvent(event)
}
}
})
}
return {
vscrollData: this.vscrollData,
vscrollParent: this,
vscrollResizeObserver: this.$_resizeObserver,
}
},
props: {
...props,
minItemSize: {
type: [Number, String],
required: true,
},
},
data () {
return {
vscrollData: {
active: true,
sizes: {},
validSizes: {},
keyField: this.keyField,
simpleArray: false,
},
}
},
computed: {
simpleArray,
itemsWithSize () {
const result = []
const { items, keyField, simpleArray } = this
const sizes = this.vscrollData.sizes
for (let i = 0; i < items.length; i++) {
const item = items[i]
const id = simpleArray ? i : item[keyField]
let size = sizes[id]
if (typeof size === 'undefined' && !this.$_undefinedMap[id]) {
size = 0
}
result.push({
item,
id,
size,
})
}
return result
},
listeners () {
const listeners = {}
for (const key in this.$listeners) {
if (key !== 'resize' && key !== 'visible') {
listeners[key] = this.$listeners[key]
}
}
return listeners
},
},
watch: {
items () {
this.forceUpdate(false)
},
simpleArray: {
handler (value) {
this.vscrollData.simpleArray = value
},
immediate: true,
},
direction (value) {
this.forceUpdate(true)
},
},
created () {
this.$_updates = []
this.$_undefinedSizes = 0
this.$_undefinedMap = {}
},
activated () {
this.vscrollData.active = true
},
deactivated () {
this.vscrollData.active = false
},
methods: {
onScrollerResize () {
const scroller = this.$refs.scroller
if (scroller) {
this.forceUpdate()
}
this.$emit('resize')
},
onScrollerVisible () {
this.$emit('vscroll:update', { force: false })
this.$emit('visible')
},
forceUpdate (clear = true) {
if (clear || this.simpleArray) {
this.vscrollData.validSizes = {}
}
this.$emit('vscroll:update', { force: true })
},
scrollToItem (index) {
const scroller = this.$refs.scroller
if (scroller) scroller.scrollToItem(index)
},
getItemSize (item, index = undefined) {
const id = this.simpleArray ? (index != null ? index : this.items.indexOf(item)) : item[this.keyField]
return this.vscrollData.sizes[id] || 0
},
scrollToBottom () {
if (this.$_scrollingToBottom) return
this.$_scrollingToBottom = true
const el = this.$el
// Item is inserted to the DOM
this.$nextTick(() => {
el.scrollTop = el.scrollHeight + 5000
// Item sizes are computed
const cb = () => {
el.scrollTop = el.scrollHeight + 5000
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight + 5000
if (this.$_undefinedSizes === 0) {
this.$_scrollingToBottom = false
} else {
requestAnimationFrame(cb)
}
})
}
requestAnimationFrame(cb)
})
},
},
}
</script>
<script>
export default {
name: 'DynamicScrollerItem',
inject: [
'vscrollData',
'vscrollParent',
'vscrollResizeObserver',
],
props: {
// eslint-disable-next-line vue/require-prop-types
item: {
required: true,
},
watchData: {
type: Boolean,
default: false,
},
/**
* Indicates if the view is actively used to display an item.
*/
active: {
type: Boolean,
required: true,
},
index: {
type: Number,
default: undefined,
},
sizeDependencies: {
type: [Array, Object],
default: null,
},
emitResize: {
type: Boolean,
default: false,
},
tag: {
type: String,
default: 'div',
},
},
computed: {
id () {
return this.vscrollData.simpleArray ? this.index : this.item[this.vscrollData.keyField]
},
size () {
return (this.vscrollData.validSizes[this.id] && this.vscrollData.sizes[this.id]) || 0
},
finalActive () {
return this.active && this.vscrollData.active
},
},
watch: {
watchData: 'updateWatchData',
id () {
if (!this.size) {
this.onDataUpdate()
}
},
finalActive (value) {
if (!this.size) {
if (value) {
if (!this.vscrollParent.$_undefinedMap[this.id]) {
this.vscrollParent.$_undefinedSizes++
this.vscrollParent.$_undefinedMap[this.id] = true
}
} else {
if (this.vscrollParent.$_undefinedMap[this.id]) {
this.vscrollParent.$_undefinedSizes--
this.vscrollParent.$_undefinedMap[this.id] = false
}
}
}
if (this.vscrollResizeObserver) {
if (value) {
this.observeSize()
} else {
this.unobserveSize()
}
} else if (value && this.$_pendingVScrollUpdate === this.id) {
this.updateSize()
}
},
},
created () {
if (this.$isServer) return
this.$_forceNextVScrollUpdate = null
this.updateWatchData()
if (!this.vscrollResizeObserver) {
for (const k in this.sizeDependencies) {
this.$watch(() => this.sizeDependencies[k], this.onDataUpdate)
}
this.vscrollParent.$on('vscroll:update', this.onVscrollUpdate)
this.vscrollParent.$on('vscroll:update-size', this.onVscrollUpdateSize)
}
},
mounted () {
if (this.vscrollData.active) {
this.updateSize()
this.observeSize()
}
},
beforeDestroy () {
this.vscrollParent.$off('vscroll:update', this.onVscrollUpdate)
this.vscrollParent.$off('vscroll:update-size', this.onVscrollUpdateSize)
this.unobserveSize()
},
methods: {
updateSize () {
if (this.finalActive) {
if (this.$_pendingSizeUpdate !== this.id) {
this.$_pendingSizeUpdate = this.id
this.$_forceNextVScrollUpdate = null
this.$_pendingVScrollUpdate = null
this.computeSize(this.id)
}
} else {
this.$_forceNextVScrollUpdate = this.id
}
},
updateWatchData () {
if (this.watchData) {
this.$_watchData = this.$watch('data', () => {
this.onDataUpdate()
}, {
deep: true,
})
} else if (this.$_watchData) {
this.$_watchData()
this.$_watchData = null
}
},
onVscrollUpdate ({ force }) {
// If not active, sechedule a size update when it becomes active
if (!this.finalActive && force) {
this.$_pendingVScrollUpdate = this.id
}
if (this.$_forceNextVScrollUpdate === this.id || force || !this.size) {
this.updateSize()
}
},
onDataUpdate () {
this.updateSize()
},
computeSize (id) {
this.$nextTick(() => {
if (this.id === id) {
const width = this.$el.offsetWidth
const height = this.$el.offsetHeight
this.applySize(width, height)
}
this.$_pendingSizeUpdate = null
})
},
applySize (width, height) {
const size = Math.round(this.vscrollParent.direction === 'vertical' ? height : width)
if (size && this.size !== size) {
if (this.vscrollParent.$_undefinedMap[this.id]) {
this.vscrollParent.$_undefinedSizes--
this.vscrollParent.$_undefinedMap[this.id] = undefined
}
this.$set(this.vscrollData.sizes, this.id, size)
this.$set(this.vscrollData.validSizes, this.id, true)
if (this.emitResize) this.$emit('resize', this.id)
}
},
observeSize () {
if (!this.vscrollResizeObserver) return
this.vscrollResizeObserver.observe(this.$el.parentNode)
this.$el.parentNode.addEventListener('resize', this.onResize)
},
unobserveSize () {
if (!this.vscrollResizeObserver) return
this.vscrollResizeObserver.unobserve(this.$el.parentNode)
this.$el.parentNode.removeEventListener('resize', this.onResize)
},
onResize (event) {
const { width, height } = event.detail.contentRect
this.applySize(width, height)
},
},
render (h) {
return h(this.tag, this.$slots.default)
},
}
</script>
export const props = {
items: {
type: Array,
required: true,
},
keyField: {
type: String,
default: 'id',
},
direction: {
type: String,
default: 'vertical',
validator: (value) => ['vertical', 'horizontal'].includes(value),
},
}
export function simpleArray () {
return this.items.length && typeof this.items[0] !== 'object'
}
/**
* See https://gitlab.com/gitlab-org/gitlab/-/issues/331267 for more information on this vendored
* dependency
*/
import config from './config'
import RecycleScroller from './components/RecycleScroller.vue'
import DynamicScroller from './components/DynamicScroller.vue'
import DynamicScrollerItem from './components/DynamicScrollerItem.vue'
export { default as IdState } from './mixins/IdState'
export {
RecycleScroller,
DynamicScroller,
DynamicScrollerItem,
}
function registerComponents (Vue, prefix) {
Vue.component(`${prefix}recycle-scroller`, RecycleScroller)
Vue.component(`${prefix}RecycleScroller`, RecycleScroller)
Vue.component(`${prefix}dynamic-scroller`, DynamicScroller)
Vue.component(`${prefix}DynamicScroller`, DynamicScroller)
Vue.component(`${prefix}dynamic-scroller-item`, DynamicScrollerItem)
Vue.component(`${prefix}DynamicScrollerItem`, DynamicScrollerItem)
}
const plugin = {
// eslint-disable-next-line no-undef
install (Vue, options) {
const finalOptions = Object.assign({}, {
installComponents: true,
componentsPrefix: '',
}, options)
for (const key in finalOptions) {
if (typeof finalOptions[key] !== 'undefined') {
config[key] = finalOptions[key]
}
}
if (finalOptions.installComponents) {
registerComponents(Vue, finalOptions.componentsPrefix)
}
},
}
export default plugin
// Auto-install
let GlobalVue = null
if (typeof window !== 'undefined') {
GlobalVue = window.Vue
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue
}
if (GlobalVue) {
GlobalVue.use(plugin)
}
import Vue from 'vue'
export default function ({
idProp = vm => vm.item.id,
} = {}) {
const store = {}
const vm = new Vue({
data () {
return {
store,
}
},
})
// @vue/component
return {
data () {
return {
idState: null,
}
},
created () {
this.$_id = null
if (typeof idProp === 'function') {
this.$_getId = () => idProp.call(this, this)
} else {
this.$_getId = () => this[idProp]
}
this.$watch(this.$_getId, {
handler (value) {
this.$nextTick(() => {
this.$_id = value
})
},
immediate: true,
})
this.$_updateIdState()
},
beforeUpdate () {
this.$_updateIdState()
},
methods: {
/**
* Initialize an idState
* @param {number|string} id Unique id for the data
*/
$_idStateInit (id) {
const factory = this.$options.idState
if (typeof factory === 'function') {
const data = factory.call(this, this)
vm.$set(store, id, data)
this.$_id = id
return data
} else {
throw new Error('[mixin IdState] Missing `idState` function on component definition.')
}
},
/**
* Ensure idState is created and up-to-date
*/
$_updateIdState () {
const id = this.$_getId()
if (id == null) {
console.warn(`No id found for IdState with idProp: '${idProp}'.`)
}
if (id !== this.$_id) {
if (!store[id]) {
this.$_idStateInit(id)
}
this.idState = store[id]
}
},
},
}
}
export let supportsPassive = false
if (typeof window !== 'undefined') {
supportsPassive = false
try {
var opts = Object.defineProperty({}, 'passive', {
get () {
supportsPassive = true
},
})
window.addEventListener('test', null, opts)
} catch (e) {}
}
......@@ -784,10 +784,10 @@
core-js-pure "^3.0.0"
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.13.10", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.14.0.tgz#46794bc20b612c5f75e62dd071e24dfd95f1cbe6"
integrity sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==
dependencies:
regenerator-runtime "^0.13.4"
......@@ -10359,6 +10359,11 @@ schema-utils@^3.0.0:
ajv "^6.12.5"
ajv-keywords "^3.5.2"
scrollparent@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/scrollparent/-/scrollparent-2.0.1.tgz#715d5b9cc57760fb22bdccc3befb5bfe06b1a317"
integrity sha1-cV1bnMV3YPsivczDvvtb/gaxoxc=
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
......@@ -12126,6 +12131,18 @@ vue-loader@^15.9.6:
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-observe-visibility@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-1.0.0.tgz#17cf1b2caf74022f0f3c95371468ddf2b9573152"
integrity sha512-s5TFh3s3h3Mhd3jaz3zGzkVHKHnc/0C/gNr30olO99+yw2hl3WBhK3ng3/f9OF+qkW4+l7GkmwfAzDAcY3lCFg==
vue-resize@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-1.0.1.tgz#c120bed4e09938771d622614f57dbcf58a5147ee"
integrity sha512-z5M7lJs0QluJnaoMFTIeGx6dIkYxOwHThlZDeQnWZBizKblb99GSejPnK37ZbNE/rVwDcYcHY+Io+AxdpY952w==
dependencies:
"@babel/runtime" "^7.13.10"
vue-router@3.4.9:
version "3.4.9"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.4.9.tgz#c016f42030ae2932f14e4748b39a1d9a0e250e66"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment