gitlab-ce/app/assets/javascripts/diffs/components/tree_list.vue

239 lines
7.1 KiB
Vue

<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import micromatch from 'micromatch';
import { debounce } from 'lodash';
import { getModifierKey } from '~/constants';
import { s__, sprintf } from '~/locale';
import { RecycleScroller } from 'vendor/vue-virtual-scroller';
import { contentTop } from '~/lib/utils/common_utils';
import DiffFileRow from './diff_file_row.vue';
const MODIFIER_KEY = getModifierKey();
const MAX_ITEMS_ON_NARROW_SCREEN = 8;
const BOTTOM_MARGIN = 16;
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
DiffFileRow,
RecycleScroller,
},
props: {
hideFileStats: {
type: Boolean,
required: true,
},
},
data() {
return {
search: '',
scrollerHeight: 0,
rowHeight: 0,
debouncedHeightCalc: null,
reviewBarHeight: 0,
largeBreakpointSize: 0,
};
},
computed: {
...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']),
...mapState('batchComments', ['reviewBarRendered']),
...mapGetters('batchComments', ['draftsCount']),
...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
let search = this.search.toLowerCase().trim();
if (search === '') {
return this.renderTreeList ? this.tree : this.allBlobs;
}
const searchSplit = search.split(',').filter((t) => t);
if (searchSplit.length > 1) {
search = `(${searchSplit.map((s) => s.replace(/(^ +| +$)/g, '')).join('|')})`;
} else {
[search] = searchSplit;
}
return this.allBlobs.reduce((acc, folder) => {
const tree = folder.tree.filter((f) =>
micromatch.contains(f.path, search, { nocase: true }),
);
if (tree.length) {
return acc.concat({
...folder,
tree,
});
}
return acc;
}, []);
},
// Flatten the treeList so there's no nested trees
// This gives us fixed row height for virtual scrolling
// in: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'c' }]
// out: [{ path: 'a', tree: [{ path: 'b' }] }, { path: 'b' }, { path: 'c' }]
flatFilteredTreeList() {
const result = [];
const createFlatten = (level) => (item) => {
result.push({
...item,
level: item.isHeader ? 0 : level,
key: item.key || item.path,
});
if (item.opened || item.isHeader) {
item.tree.forEach(createFlatten(level + 1));
}
};
this.filteredTreeList.forEach(createFlatten(0));
return result;
},
reviewBarEnabled() {
return this.draftsCount > 0;
},
},
watch: {
reviewBarEnabled() {
this.debouncedHeightCalc();
},
calculateReviewBarHeight() {
this.debouncedHeightCalc();
},
},
created() {
this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50);
},
mounted() {
const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height');
const breakpointProp = getComputedStyle(window.document.body).getPropertyValue(
'--breakpoint-lg',
);
this.largeBreakpointSize = parseInt(breakpointProp, 10);
this.rowHeight = parseInt(heightProp, 10);
this.calculateScrollerHeight();
let stop;
// eslint-disable-next-line prefer-const
stop = this.$watch(
() => this.reviewBarRendered,
(enabled) => {
if (!enabled) return;
this.calculateReviewBarHeight();
stop();
},
{ immediate: true },
);
window.addEventListener('resize', this.debouncedHeightCalc, { passive: true });
},
beforeDestroy() {
window.removeEventListener('resize', this.debouncedHeightCalc, { passive: true });
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'goToFile']),
clearSearch() {
this.search = '';
},
calculateScrollerHeight() {
if (window.matchMedia(`(max-width: ${this.largeBreakpointSize - 1}px)`).matches) {
this.calculateMobileScrollerHeight();
} else {
let clipping = BOTTOM_MARGIN;
if (this.reviewBarEnabled) clipping += this.reviewBarHeight;
this.scrollerHeight = this.$refs.scrollRoot.clientHeight - clipping;
}
},
calculateMobileScrollerHeight() {
const maxItems = Math.min(MAX_ITEMS_ON_NARROW_SCREEN, this.flatFilteredTreeList.length);
this.scrollerHeight = Math.min(maxItems * this.rowHeight, window.innerHeight - contentTop());
},
calculateReviewBarHeight() {
this.reviewBarHeight = document.querySelector('.js-review-bar')?.offsetHeight || 0;
},
},
searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), {
MODIFIER_KEY,
}),
DiffFileRow,
};
</script>
<template>
<div ref="wrapper" class="tree-list-holder d-flex flex-column" data-testid="file-tree-container">
<div class="gl-pb-3 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<gl-icon name="search" class="gl-absolute gl-top-3 gl-left-3 tree-list-icon" />
<label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
<input
id="diff-tree-search"
v-model="search"
:placeholder="$options.searchPlaceholder"
type="search"
name="diff-tree-search"
class="form-control"
data-testid="diff-tree-search"
/>
<button
v-show="search"
:aria-label="__('Clear search')"
type="button"
class="gl-absolute gl-top-3 bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
@click="clearSearch"
>
<gl-icon name="close" class="gl-top-3 gl-right-1 tree-list-icon" />
</button>
</div>
</div>
<div
ref="scrollRoot"
:class="{ 'tree-list-blobs': !renderTreeList || search }"
class="gl-flex-grow-1 mr-tree-list"
>
<recycle-scroller
v-if="flatFilteredTreeList.length"
:style="{ height: `${scrollerHeight}px` }"
:items="flatFilteredTreeList"
:item-size="rowHeight"
:buffer="100"
key-field="key"
>
<template #default="{ item }">
<diff-file-row
:file="item"
:level="item.level"
:viewed-files="viewedDiffFileIds"
:hide-file-stats="hideFileStats"
:current-diff-file-id="currentDiffFileId"
:style="{ '--level': item.level }"
:class="{ 'tree-list-parent': item.level > 0 }"
class="gl-relative"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="(path) => goToFile({ path })"
/>
</template>
<template #after>
<div class="tree-list-gutter"></div>
</template>
</recycle-scroller>
<p v-else class="prepend-top-20 append-bottom-20 text-center">
{{ s__('MergeRequest|No files found') }}
</p>
</div>
</div>
</template>
<style>
.tree-list-blobs .file-row-name {
margin-left: 12px;
}
.tree-list-icon:not(button) {
pointer-events: none;
}
</style>