diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/file_finder/index.vue')
-rw-r--r-- | app/assets/javascripts/vue_shared/components/file_finder/index.vue | 299 |
1 files changed, 299 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue new file mode 100644 index 00000000000..b57455adaad --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -0,0 +1,299 @@ +<script> +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Mousetrap from 'mousetrap'; +import VirtualList from 'vue-virtual-scroll-list'; +import Item from './item.vue'; +import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; + +export const MAX_FILE_FINDER_RESULTS = 40; +export const FILE_FINDER_ROW_HEIGHT = 55; +export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33; + +const originalStopCallback = Mousetrap.stopCallback; + +export default { + components: { + Item, + VirtualList, + }, + props: { + files: { + type: Array, + required: true, + }, + visible: { + type: Boolean, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + clearSearchOnClose: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + focusedIndex: -1, + searchText: '', + mouseOver: false, + cancelMouseOver: false, + }; + }, + computed: { + filteredBlobs() { + const searchText = this.searchText.trim(); + + if (searchText === '') { + return this.files.slice(0, MAX_FILE_FINDER_RESULTS); + } + + return fuzzaldrinPlus.filter(this.files, searchText, { + key: 'path', + maxResults: MAX_FILE_FINDER_RESULTS, + }); + }, + filteredBlobsLength() { + return this.filteredBlobs.length; + }, + listShowCount() { + return this.filteredBlobsLength ? Math.min(this.filteredBlobsLength, 5) : 1; + }, + listHeight() { + return this.filteredBlobsLength ? FILE_FINDER_ROW_HEIGHT : FILE_FINDER_EMPTY_ROW_HEIGHT; + }, + showClearInputButton() { + return this.searchText.trim() !== ''; + }, + }, + watch: { + visible() { + this.$nextTick(() => { + if (!this.visible) { + if (this.clearSearchOnClose) { + this.searchText = ''; + } + } else { + this.focusedIndex = 0; + + if (this.$refs.searchInput) { + this.$refs.searchInput.focus(); + } + } + }); + }, + searchText() { + this.focusedIndex = -1; + + this.$nextTick(() => { + this.focusedIndex = 0; + }); + }, + focusedIndex() { + if (!this.mouseOver) { + this.$nextTick(() => { + const el = this.$refs.virtualScrollList.$el; + const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT; + const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT; + + if (this.focusedIndex === 0) { + // if index is the first index, scroll straight to start + el.scrollTop = 0; + } else if (this.focusedIndex === this.filteredBlobsLength - 1) { + // if index is the last index, scroll to the end + el.scrollTop = this.filteredBlobsLength * FILE_FINDER_ROW_HEIGHT; + } else if (scrollTop >= bottom + el.scrollTop) { + // if element is off the bottom of the scroll list, scroll down one item + el.scrollTop = scrollTop - bottom + FILE_FINDER_ROW_HEIGHT; + } else if (scrollTop < el.scrollTop) { + // if element is off the top of the scroll list, scroll up one item + el.scrollTop = scrollTop; + } + }); + } + }, + }, + mounted() { + if (this.files.length) { + this.focusedIndex = 0; + } + + Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => { + if (e.preventDefault) { + e.preventDefault(); + } + + this.toggle(!this.visible); + }); + + Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo); + }, + methods: { + toggle(visible) { + this.$emit('toggle', visible); + }, + clearSearchInput() { + this.searchText = ''; + + this.$nextTick(() => { + this.$refs.searchInput.focus(); + }); + }, + onKeydown(e) { + switch (e.keyCode) { + case UP_KEY_CODE: + e.preventDefault(); + this.mouseOver = false; + this.cancelMouseOver = true; + if (this.focusedIndex > 0) { + this.focusedIndex -= 1; + } else { + this.focusedIndex = this.filteredBlobsLength - 1; + } + break; + case DOWN_KEY_CODE: + e.preventDefault(); + this.mouseOver = false; + this.cancelMouseOver = true; + if (this.focusedIndex < this.filteredBlobsLength - 1) { + this.focusedIndex += 1; + } else { + this.focusedIndex = 0; + } + break; + default: + break; + } + }, + onKeyup(e) { + switch (e.keyCode) { + case ENTER_KEY_CODE: + this.openFile(this.filteredBlobs[this.focusedIndex]); + break; + case ESC_KEY_CODE: + this.toggle(false); + break; + default: + break; + } + }, + openFile(file) { + this.toggle(false); + this.$emit('click', file); + }, + onMouseOver(index) { + if (!this.cancelMouseOver) { + this.mouseOver = true; + this.focusedIndex = index; + } + }, + onMouseMove(index) { + this.cancelMouseOver = false; + this.onMouseOver(index); + }, + mousetrapStopCallback(e, el, combo) { + if ( + (combo === 't' && el.classList.contains('dropdown-input-field')) || + el.classList.contains('inputarea') + ) { + return true; + } else if (combo === 'command+p' || combo === 'ctrl+p') { + return false; + } + + return originalStopCallback(e, el, combo); + }, + }, +}; +</script> + +<template> + <div class="file-finder-overlay" @mousedown.self="toggle(false)"> + <div class="dropdown-menu diff-file-changes file-finder show"> + <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input"> + <input + ref="searchInput" + v-model="searchText" + :placeholder="__('Search files')" + type="search" + class="dropdown-input-field" + autocomplete="off" + @keydown="onKeydown($event)" + @keyup="onKeyup($event)" + /> + <i + :class="{ + hidden: showClearInputButton, + }" + aria-hidden="true" + class="fa fa-search dropdown-input-search" + ></i> + <i + :aria-label="__('Clear search input')" + role="button" + class="fa fa-times dropdown-input-clear" + @click="clearSearchInput" + ></i> + </div> + <div> + <virtual-list ref="virtualScrollList" :size="listHeight" :remain="listShowCount" wtag="ul"> + <template v-if="filteredBlobsLength"> + <li v-for="(file, index) in filteredBlobs" :key="file.key"> + <item + :file="file" + :search-text="searchText" + :focused="index === focusedIndex" + :index="index" + :show-diff-stats="showDiffStats" + class="disable-hover" + @click="openFile" + @mouseover="onMouseOver" + @mousemove="onMouseMove" + /> + </li> + </template> + <li v-else class="dropdown-menu-empty-item"> + <div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8"> + <template v-if="loading"> + {{ __('Loading...') }} + </template> + <template v-else> + {{ __('No files found.') }} + </template> + </div> + </li> + </virtual-list> + </div> + </div> + </div> +</template> + +<style scoped> +.file-finder-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 200; +} + +.file-finder { + top: 10px; + left: 50%; + transform: translateX(-50%); +} + +.diff-file-changes { + top: 50px; + max-height: 327px; +} +</style> |