Skip to content
Snippets Groups Projects
Unverified Commit 6e5461d6 authored by Phil Hughes's avatar Phil Hughes
Browse files

Added fuzzy file finder to merge requests

parent 55cb4bc9
No related branches found
No related tags found
No related merge requests found
Showing
with 269 additions and 233 deletions
Loading
Loading
@@ -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']),
Loading
Loading
Loading
Loading
@@ -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>
Loading
Loading
@@ -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">
Loading
Loading
@@ -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',
Loading
Loading
Loading
Loading
@@ -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 () => {};
Loading
Loading
@@ -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;
 
Loading
Loading
Loading
Loading
@@ -29,4 +29,5 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: true,
fileFinderVisible: false,
});
Loading
Loading
@@ -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';
Loading
Loading
@@ -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,
Loading
Loading
@@ -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']),
Loading
Loading
@@ -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}`);
},
},
};
Loading
Loading
@@ -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">
Loading
Loading
// 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
Loading
Loading
<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,
});
Loading
Loading
@@ -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;
 
Loading
Loading
@@ -72,7 +91,11 @@ export default {
});
},
searchText() {
this.focusedIndex = 0;
this.focusedIndex = -1;
this.$nextTick(() => {
this.focusedIndex = 0;
});
},
focusedIndex() {
if (!this.mouseOver) {
Loading
Loading
@@ -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 = '';
 
Loading
Loading
@@ -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) {
Loading
Loading
@@ -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"
Loading
Loading
@@ -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"
Loading
Loading
@@ -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"
Loading
Loading
@@ -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';
 
Loading
Loading
@@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60;
 
export default {
components: {
Icon,
ChangedFileIcon,
FileIcon,
},
Loading
Loading
@@ -27,6 +29,11 @@ export default {
type: Number,
required: true,
},
showDiffStats: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
pathWithEllipsis() {
Loading
Loading
@@ -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>
Loading
Loading
@@ -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;
Loading
Loading
Loading
Loading
@@ -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;
}
Loading
Loading
@@ -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
Loading
Loading
---
title: Added fuzzy file finder to merge requests
merge_request:
author:
type: changed
Loading
Loading
@@ -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"
Loading
Loading
Loading
Loading
@@ -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();
 
Loading
Loading
@@ -130,14 +119,4 @@ describe('Diffs tree list component', () => {
});
});
});
describe('clearSearch', () => {
it('resets search', () => {
vm.search = 'test';
vm.$el.querySelector('.tree-list-clear-icon').click();
expect(vm.search).toBe('');
});
});
});
Loading
Loading
@@ -242,7 +242,11 @@ describe('Diffs Module Getters', () => {
},
};
 
expect(getters.allBlobs(localState)).toEqual([
expect(
getters.allBlobs(localState, {
flatBlobsList: getters.flatBlobsList(localState),
}),
).toEqual([
{
isHeader: true,
path: '/',
Loading
Loading
import Vue from 'vue';
import Mousetrap from 'mousetrap';
import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
Loading
Loading
@@ -72,73 +71,6 @@ describe('ide component', () => {
});
});
 
describe('file finder', () => {
beforeEach(done => {
spyOn(vm, 'toggleFileFinder');
vm.$store.state.fileFindVisible = true;
vm.$nextTick(done);
});
it('calls toggleFileFinder on `t` key press', done => {
Mousetrap.trigger('t');
vm.$nextTick()
.then(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls toggleFileFinder on `command+p` key press', done => {
Mousetrap.trigger('command+p');
vm.$nextTick()
.then(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls toggleFileFinder on `ctrl+p` key press', done => {
Mousetrap.trigger('ctrl+p');
vm.$nextTick()
.then(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('always allows `command+p` to trigger toggleFileFinder', () => {
expect(
vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
).toBe(false);
});
it('always allows `ctrl+p` to trigger toggleFileFinder', () => {
expect(
vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
).toBe(false);
});
it('onlys handles `t` when focused in input-field', () => {
expect(
vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
).toBe(true);
});
it('stops callback in monaco editor', () => {
setFixtures('<div class="inputarea"></div>');
expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true);
});
});
it('shows error message when set', done => {
expect(vm.$el.querySelector('.flash-container')).toBe(null);
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment