Commit 3bed077c authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'diff-file-finder' into 'master'

Added fuzzy file finder to merge requests

Closes #53304

See merge request gitlab-org/gitlab-ce!24434
parents d8bbaae6 6e5461d6
Pipeline #46234759 passed with stages
in 50 minutes and 31 seconds
......@@ -129,6 +129,10 @@ export default {
created() {
this.adjustView();
eventHub.$once('fetchedNotesData', this.setDiscussions);
eventHub.$once('fetchDiffData', this.fetchData);
},
beforeDestroy() {
eventHub.$off('fetchDiffData', this.fetchData);
},
methods: {
...mapActions(['startTaskList']),
......
......@@ -13,39 +13,17 @@ export default {
Icon,
FileRow,
},
data() {
return {
search: '',
};
},
computed: {
...mapState('diffs', ['tree', 'addedLines', 'removedLines', 'renderTreeList']),
...mapGetters('diffs', ['allBlobs', 'diffFilesLength']),
filteredTreeList() {
const search = this.search.toLowerCase().trim();
if (search === '') return this.renderTreeList ? this.tree : this.allBlobs;
return this.allBlobs.reduce((acc, folder) => {
const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
if (tree.length) {
return acc.concat({
...folder,
tree,
});
}
return acc;
}, []);
return this.renderTreeList ? this.tree : this.allBlobs;
},
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
clearSearch() {
this.search = '';
},
...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile', 'toggleFileFinder']),
},
shortcutKeyCharacter: `${/Mac/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'}+P`,
FileRowStats,
};
</script>
......@@ -55,21 +33,17 @@ export default {
<div class="append-bottom-8 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<icon name="search" class="position-absolute tree-list-icon" />
<input
v-model="search"
:placeholder="s__('MergeRequest|Filter files')"
type="search"
class="form-control"
/>
<button
v-show="search"
:aria-label="__('Clear search')"
type="button"
class="position-absolute bg-transparent tree-list-icon tree-list-clear-icon border-0 p-0"
@click="clearSearch"
class="form-control text-left text-secondary"
@click="toggleFileFinder(true)"
>
<icon name="close" />
{{ s__('MergeRequest|Search files') }}
</button>
<span
class="position-absolute text-secondary diff-tree-search-shortcut"
v-html="$options.shortcutKeyCharacter"
></span>
</div>
</div>
<div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
......@@ -104,4 +78,15 @@ export default {
.tree-list-blobs .file-row-name {
margin-left: 12px;
}
.diff-tree-search-shortcut {
top: 50%;
right: 10px;
transform: translateY(-50%);
pointer-events: none;
}
.tree-list-icon {
pointer-events: none;
}
</style>
import Vue from 'vue';
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
import { TREE_LIST_STORAGE_KEY } from './constants';
export default function initDiffsApp(store) {
const fileFinderEl = document.getElementById('js-diff-file-finder');
if (fileFinderEl) {
// eslint-disable-next-line no-new
new Vue({
el: fileFinderEl,
store,
computed: {
...mapState('diffs', ['fileFinderVisible', 'isLoading']),
...mapGetters('diffs', ['flatBlobsList']),
},
watch: {
fileFinderVisible(newVal, oldVal) {
if (newVal && !oldVal && !this.flatBlobsList.length) {
eventHub.$emit('fetchDiffData');
}
},
},
methods: {
...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
openFile(file) {
window.mrTabs.tabShown('diffs');
this.scrollToFile(file.path);
},
},
render(createElement) {
return createElement(FindFile, {
props: {
files: this.flatBlobsList,
visible: this.fileFinderVisible,
loading: this.isLoading,
showDiffStats: true,
clearSearchOnClose: false,
},
on: {
toggle: this.toggleFileFinder,
click: this.openFile,
},
class: ['diff-file-finder'],
style: {
display: this.fileFinderVisible ? '' : 'none',
},
});
},
});
}
return new Vue({
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
......
......@@ -296,5 +296,9 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
}
};
export const toggleFileFinder = ({ commit }, visible) => {
commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -74,24 +74,25 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash);
export const allBlobs = state =>
Object.values(state.treeEntries)
.filter(f => f.type === 'blob')
.reduce((acc, file) => {
const { parentPath } = file;
if (parentPath && !acc.some(f => f.path === parentPath)) {
acc.push({
path: parentPath,
isHeader: true,
tree: [],
});
}
acc.find(f => f.path === parentPath).tree.push(file);
return acc;
}, []);
export const flatBlobsList = state =>
Object.values(state.treeEntries).filter(f => f.type === 'blob');
export const allBlobs = (state, getters) =>
getters.flatBlobsList.reduce((acc, file) => {
const { parentPath } = file;
if (parentPath && !acc.some(f => f.path === parentPath)) {
acc.push({
path: parentPath,
isHeader: true,
tree: [],
});
}
acc.find(f => f.path === parentPath).tree.push(file);
return acc;
}, []);
export const diffFilesLength = state => state.diffFiles.length;
......
......@@ -29,4 +29,5 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: true,
fileFinderVisible: false,
});
......@@ -22,3 +22,4 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
export const SET_TREE_DATA = 'SET_TREE_DATA';
export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
......@@ -244,4 +244,7 @@ export default {
[types.SET_SHOW_WHITESPACE](state, showWhitespace) {
state.showWhitespace = showWhitespace;
},
[types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) {
state.fileFinderVisible = visible;
},
};
<script>
import Vue from 'vue';
import Mousetrap from 'mousetrap';
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
NewModal,
......@@ -42,21 +39,18 @@ export default {
'emptyStateSvgPath',
'currentProjectId',
'errorMessage',
'loading',
]),
...mapGetters([
'activeFile',
'hasChanges',
'someUncommittedChanges',
'isCommitModeActive',
'allBlobs',
]),
...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
if (e.preventDefault) {
e.preventDefault();
}
this.toggleFileFinder(!this.fileFindVisible);
});
Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
},
methods: {
...mapActions(['toggleFileFinder']),
......@@ -70,17 +64,8 @@ export default {
});
return returnValue;
},
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);
openFile(file) {
this.$router.push(`/project${file.url}`);
},
},
};
......@@ -90,7 +75,14 @@ export default {
<article class="ide position-relative d-flex flex-column align-items-stretch">
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
<find-file v-show="fileFindVisible" />
<find-file
v-show="fileFindVisible"
:files="allBlobs"
:visible="fileFindVisible"
:loading="loading"
@toggle="toggleFileFinder"
@click="openFile"
/>
<ide-sidebar />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
......
// Fuzzy file finder
export const MAX_FILE_FINDER_RESULTS = 40;
export const FILE_FINDER_ROW_HEIGHT = 55;
export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
export const MAX_WINDOW_HEIGHT_COMPACT = 750;
// Commit message textarea
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
import Item from './item.vue';
import router from '../../ide_router';
import {
MAX_FILE_FINDER_RESULTS,
FILE_FINDER_ROW_HEIGHT,
FILE_FINDER_EMPTY_ROW_HEIGHT,
} from '../../constants';
import {
UP_KEY_CODE,
DOWN_KEY_CODE,
ENTER_KEY_CODE,
ESC_KEY_CODE,
} from '../../../lib/utils/keycodes';
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: 0,
focusedIndex: -1,
searchText: '',
mouseOver: false,
cancelMouseOver: false,
};
},
computed: {
...mapGetters(['allBlobs']),
...mapState(['fileFindVisible', 'loading']),
filteredBlobs() {
const searchText = this.searchText.trim();
if (searchText === '') {
return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS);
return this.files.slice(0, MAX_FILE_FINDER_RESULTS);
}
return fuzzaldrinPlus.filter(this.allBlobs, searchText, {
return fuzzaldrinPlus.filter(this.files, searchText, {
key: 'path',
maxResults: MAX_FILE_FINDER_RESULTS,
});
......@@ -58,10 +75,12 @@ export default {
},
},
watch: {
fileFindVisible() {
visible() {
this.$nextTick(() => {
if (!this.fileFindVisible) {
this.searchText = '';
if (!this.visible) {
if (this.clearSearchOnClose) {
this.searchText = '';
}
} else {
this.focusedIndex = 0;
......@@ -72,7 +91,11 @@ export default {
});
},
searchText() {
this.focusedIndex = 0;
this.focusedIndex = -1;
this.$nextTick(() => {
this.focusedIndex = 0;
});
},
focusedIndex() {
if (!this.mouseOver) {
......@@ -98,8 +121,25 @@ export default {
}
},
},
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: {
...mapActions(['toggleFileFinder']),
toggle(visible) {
this.$emit('toggle', visible);
},
clearSearchInput() {
this.searchText = '';
......@@ -139,15 +179,15 @@ export default {
this.openFile(this.filteredBlobs[this.focusedIndex]);
break;
case ESC_KEY_CODE:
this.toggleFileFinder(false);
this.toggle(false);
break;
default:
break;
}
},
openFile(file) {
this.toggleFileFinder(false);
router.push(`/project${file.url}`);
this.toggle(false);
this.$emit('click', file);
},
onMouseOver(index) {
if (!this.cancelMouseOver) {
......@@ -159,14 +199,26 @@ export default {
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="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false)">
<div class="dropdown-menu diff-file-changes ide-file-finder show">
<div class="dropdown-input">
<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"
......@@ -186,9 +238,6 @@ export default {
></i>
<i
:aria-label="__('Clear search input')"
:class="{
show: showClearInputButton,
}"
role="button"
class="fa fa-times dropdown-input-clear"
@click="clearSearchInput"
......@@ -203,6 +252,7 @@ export default {
:search-text="searchText"
:focused="index === focusedIndex"
:index="index"
:show-diff-stats="showDiffStats"
class="disable-hover"
@click="openFile"
@mouseover="onMouseOver"
......@@ -225,3 +275,25 @@ export default {
</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>
<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';
......@@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60;
export default {
components: {
Icon,
ChangedFileIcon,
FileIcon,
},
......@@ -27,6 +29,11 @@ export default {
type: Number,
required: true,
},
showDiffStats: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
pathWithEllipsis() {
......@@ -97,8 +104,23 @@ export default {
</span>
</span>
</span>
<span v-if="file.changed || file.tempFile" class="diff-changed-stats">
<changed-file-icon :file="file" />
<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>
......@@ -816,26 +816,6 @@ $ide-commit-header-height: 48px;
z-index: 1;
}
.ide-file-finder-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
}
.ide-file-finder {
top: 10px;
left: 50%;
transform: translateX(-50%);
.highlighted {
color: $blue-500;
font-weight: $gl-font-weight-bold;
}
}
.ide-commit-message-field {
height: 200px;
background-color: $white-light;
......
......@@ -986,3 +986,9 @@
width: $ci-action-icon-size-lg;
}
}
.merge-request-details .file-finder-overlay.diff-file-finder {
position: fixed;
z-index: 99999;
background: $black-transparent;
}
......@@ -59,6 +59,7 @@
#js-vue-discussion-counter
.tab-content#diff-notes-app
#js-diff-file-finder
#notes.notes.tab-pane.voting_notes
.row
%section.col-md-12
......
---
title: Added fuzzy file finder to merge requests
merge_request:
author:
type: changed
......@@ -4438,10 +4438,10 @@ msgstr ""
msgid "MergeRequest| %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}"
msgstr ""
msgid "MergeRequest|Filter files"
msgid "MergeRequest|No files found"
msgstr ""
msgid "MergeRequest|No files found"
msgid "MergeRequest|Search files"
msgstr ""
msgid "Merged"
......
......@@ -83,17 +83,6 @@ describe('Diffs tree list component', () => {
expect(vm.$el.querySelectorAll('.file-row')[1].textContent).toContain('app');
});
it('filters tree list to blobs matching search', done => {
vm.search = 'app/index';
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.file-row').length).toBe(1);
expect(vm.$el.querySelectorAll('.file-row')[0].textContent).toContain('index.js');
done();
});
});
it('calls toggleTreeOpen when clicking folder', () => {
spyOn(vm.$store, 'dispatch').and.stub();