diff options
author | Phil Hughes <me@iamphill.com> | 2019-01-16 15:17:10 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2019-02-05 11:29:49 +0000 |
commit | 6e5461d67f52cacc2c9ba408c8f6fddb1e9e417d (patch) | |
tree | 14fd11c8ca5dd257047f5c2997c33a9b36fe1931 /app/assets/javascripts/vue_shared/components/file_finder | |
parent | 55cb4bc9cafca0c838192b54f9daa4b2bc0b86b0 (diff) | |
download | gitlab-ce-6e5461d67f52cacc2c9ba408c8f6fddb1e9e417d.tar.gz |
Added fuzzy file finder to merge requests
Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/53304
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/file_finder')
-rw-r--r-- | app/assets/javascripts/vue_shared/components/file_finder/index.vue | 299 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/file_finder/item.vue | 126 |
2 files changed, 425 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> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue new file mode 100644 index 00000000000..73511879ff2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue @@ -0,0 +1,126 @@ +<script> +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '../../../vue_shared/components/file_icon.vue'; +import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue'; + +const MAX_PATH_LENGTH = 60; + +export default { + components: { + Icon, + ChangedFileIcon, + FileIcon, + }, + props: { + file: { + type: Object, + required: true, + }, + focused: { + type: Boolean, + required: true, + }, + searchText: { + type: String, + required: true, + }, + index: { + type: Number, + required: true, + }, + showDiffStats: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + pathWithEllipsis() { + const { path } = this.file; + + return path.length < MAX_PATH_LENGTH + ? path + : `...${path.substr(path.length - MAX_PATH_LENGTH)}`; + }, + nameSearchTextOccurences() { + return fuzzaldrinPlus.match(this.file.name, this.searchText); + }, + pathSearchTextOccurences() { + return fuzzaldrinPlus.match(this.pathWithEllipsis, this.searchText); + }, + }, + methods: { + clickRow() { + this.$emit('click', this.file); + }, + mouseOverRow() { + this.$emit('mouseover', this.index); + }, + mouseMove() { + this.$emit('mousemove', this.index); + }, + }, +}; +</script> + +<template> + <button + :class="{ + 'is-focused': focused, + }" + type="button" + class="diff-changed-file" + @click.prevent="clickRow" + @mouseover="mouseOverRow" + @mousemove="mouseMove" + > + <file-icon + :file-name="file.name" + :size="16" + css-classes="diff-file-changed-icon append-right-8" + /> + <span class="diff-changed-file-content append-right-8"> + <strong class="diff-changed-file-name"> + <span + v-for="(char, charIndex) in file.name.split('')" + :key="charIndex + char" + :class="{ + highlighted: nameSearchTextOccurences.indexOf(charIndex) >= 0, + }" + v-text="char" + > + </span> + </strong> + <span class="diff-changed-file-path prepend-top-5"> + <span + v-for="(char, charIndex) in pathWithEllipsis.split('')" + :key="charIndex + char" + :class="{ + highlighted: pathSearchTextOccurences.indexOf(charIndex) >= 0, + }" + v-text="char" + > + </span> + </span> + </span> + <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats"> + <span v-if="showDiffStats"> + <span class="cgreen bold"> + <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }} + </span> + <span class="cred bold ml-1"> + <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }} + </span> + </span> + <changed-file-icon v-else :file="file" /> + </span> + </button> +</template> + +<style scoped> +.highlighted { + color: #1f78d1; + font-weight: 600; +} +</style> |