Commit bb8e4268 authored by Filipa Lacerda's avatar Filipa Lacerda

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

Added fuzzy file finder to web IDE

Closes #44841

See merge request gitlab-org/gitlab-ce!18323
parents b0f7ab7f bdc84d4f
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
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';
export default {
components: {
Item,
VirtualList,
},
data() {
return {
focusedIndex: 0,
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 fuzzaldrinPlus
.filter(this.allBlobs, searchText, {
key: 'path',
maxResults: MAX_FILE_FINDER_RESULTS,
})
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
},
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: {
fileFindVisible() {
this.$nextTick(() => {
if (!this.fileFindVisible) {
this.searchText = '';
} else {
this.focusedIndex = 0;
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
}
});
},
searchText() {
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;
}
});
}
},
},
methods: {
...mapActions(['toggleFileFinder']),
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.toggleFileFinder(false);
break;
default:
break;
}
},
openFile(file) {
this.toggleFileFinder(false);
router.push(`/project${file.url}`);
},
onMouseOver(index) {
if (!this.cancelMouseOver) {
this.mouseOver = true;
this.focusedIndex = index;
}
},
onMouseMove(index) {
this.cancelMouseOver = false;
this.onMouseOver(index);
},
},
};
</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">
<input
type="search"
class="dropdown-input-field"
:placeholder="__('Search files')"
autocomplete="off"
v-model="searchText"
ref="searchInput"
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
:class="{
hidden: showClearInputButton
}"
></i>
<i
role="button"
:aria-label="__('Clear search input')"
class="fa fa-times dropdown-input-clear"
:class="{
show: showClearInputButton
}"
@click="clearSearchInput"
></i>
</div>
<div>
<virtual-list
:size="listHeight"
:remain="listShowCount"
wtag="ul"
ref="virtualScrollList"
>
<template v-if="filteredBlobsLength">
<li
v-for="(file, index) in filteredBlobs"
:key="file.key"
>
<item
class="disable-hover"
:file="file"
:search-text="searchText"
:focused="index === focusedIndex"
:index="index"
@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>
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import FileIcon from '../../../vue_shared/components/file_icon.vue';
import ChangedFileIcon from '../changed_file_icon.vue';
const MAX_PATH_LENGTH = 60;
export default {
components: {
ChangedFileIcon,
FileIcon,
},
props: {
file: {
type: Object,
required: true,
},
focused: {
type: Boolean,
required: true,
},
searchText: {
type: String,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
pathWithEllipsis() {
const path = this.file.path;
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
type="button"
class="diff-changed-file"
:class="{
'is-focused': focused,
}"
@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, index) in file.name.split('')"
:key="index + char"
:class="{
highlighted: nameSearchTextOccurences.indexOf(index) >= 0,
}"
v-text="char"
>
</span>
</strong>
<span
class="diff-changed-file-path prepend-top-5"
>
<span
v-for="(char, index) in pathWithEllipsis.split('')"
:key="index + char"
:class="{
highlighted: pathSearchTextOccurences.indexOf(index) >= 0,
}"
v-text="char"
>
</span>
</span>
</span>
<span
v-if="file.changed || file.tempFile"
class="diff-changed-stats"
>
<changed-file-icon
:file="file"
/>
</span>
</button>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue';
import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import Mousetrap from 'mousetrap';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_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';
export default {
components: {
ideSidebar,
ideContextbar,
repoTabs,
ideStatusBar,
repoEditor,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
ideSidebar,
ideContextbar,
repoTabs,
ideStatusBar,
repoEditor,
FindFile,
},
noChangesStateSvgPath: {
type: String,
required: true,
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
noChangesStateSvgPath: {
type: String,
required: true,
},
committedStateSvgPath: {
type: String,
required: true,
},
},
committedStateSvgPath: {
type: String,
required: true,
computed: {
...mapState([
'changedFiles',
'openFiles',
'viewer',
'currentMergeRequestId',
'fileFindVisible',
]),
...mapGetters(['activeFile', 'hasChanges']),
},
},
computed: {
...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']),
...mapGetters(['activeFile', 'hasChanges']),
},
mounted() {
const returnValue = 'Are you sure you want to lose unsaved changes?';
window.onbeforeunload = e => {
if (!this.changedFiles.length) return undefined;
mounted() {
const returnValue = 'Are you sure you want to lose unsaved changes?';
window.onbeforeunload = e => {
if (!this.changedFiles.length) return undefined;
Object.assign(e, {
returnValue,
});
return returnValue;
};
Object.assign(e, {
returnValue,
Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
if (e.preventDefault) {
e.preventDefault();
}
this.toggleFileFinder(!this.fileFindVisible);
});
return returnValue;
};
},
};
Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
},
methods: {
...mapActions(['toggleFileFinder']),
mousetrapStopCallback(e, el, combo) {
if (combo === 't' && el.classList.contains('dropdown-input-field')) {
return true;
} else if (combo === 'command+p' || combo === 'ctrl+p') {
return false;
}
return originalStopCallback(e, el, combo);
},
},
};
</script>
<template>
<div
class="ide-view"
>
<find-file
v-show="fileFindVisible"
/>
<ide-sidebar />
<div
class="multi-file-edit-pane"
......
// 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;
// Commit message textarea
export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
import _ from 'underscore';
import store from '../stores';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import gitlabTheme from './themes/gl_theme';
import keymap from './keymap.json';
export const clearDomElement = el => {
if (!el || !el.firstChild) return;
......@@ -53,6 +55,8 @@ export default class Editor {
)),
);
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
......@@ -73,6 +77,8 @@ export default class Editor {
})),
);
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
......@@ -189,4 +195,31 @@ export default class Editor {
static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700;
}
addCommands() {
const getKeyCode = key => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? this.monaco.KeyCode[key] : this.monaco.KeyMod[key];
};
keymap.forEach(command => {
const keybindings = command.bindings.map(binding => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
this.instance.addAction({
id: command.id,
label: command.label,
keybindings,
run() {
store.dispatch(command.action.name, command.action.params);
return null;
},
});
});
}
}
[
{
"id": "file-finder",
"label": "File finder",
"bindings": ["CtrlCmd+KEY_P"],
"action": {
"name": "toggleFileFinder",
"params": true
}
}
]
......@@ -137,6 +137,9 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => {
commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
};
export const toggleFileFinder = ({ commit }, fileFindVisible) =>
commit(types.TOGGLE_FILE_FINDER, fileFindVisible);
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
......
......@@ -42,4 +42,17 @@ export const collapseButtonTooltip = state =>
export const hasMergeRequest = state => !!state.currentMergeRequestId;
export const allBlobs = state =>
Object.keys(state.entries)
.reduce((acc, key) => {
const entry = state.entries[key];
if (entry.type === 'blob') {
acc.push(entry);
}
return acc;
}, [])
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
......@@ -58,3 +58,5 @@ export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
......@@ -100,6 +100,11 @@ export default {
delayViewerUpdated,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
Object.assign(state, {
fileFindVisible,
});
},
[types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
const changedFile = state.changedFiles.find(f => f.path === file.path);
......
......@@ -4,6 +4,7 @@ export default {
[types.SET_FILE_ACTIVE](state, { path, active }) {
Object.assign(state.entries[path], {
active,
lastOpenedAt: new Date().getTime(),
});
if (active && !state.entries[path].pending) {
......
......@@ -18,4 +18,5 @@ export default () => ({
entries: {},
viewer: 'editor',
delayViewerUpdated: false,
fileFindVisible: false,
});
......@@ -42,6 +42,7 @@ export const dataStructure = () => ({
viewMode: 'edit',
previewMode: null,
size: 0,
lastOpenedAt: 0,
});
export const decorateData = entity => {
......
export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
......@@ -472,6 +472,7 @@ img.emoji {
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-8 { margin-bottom: $grid-size; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
......
......@@ -43,7 +43,7 @@
border-color: $gray-darkest;
}
[data-toggle="dropdown"] {
[data-toggle='dropdown'] {
outline: 0;
}
}
......@@ -172,7 +172,11 @@
color: $brand-danger;
}
&:hover,
&.disable-hover {
text-decoration: none;
}
&:not(.disable-hover):hover,
&:active,
&:focus,
&.is-focused {
......@@ -508,17 +512,16 @@
}
&.is-indeterminate::before {
content: "\f068";
content: '\f068';
}
&.is-active::before {
content: "\f00c";
content: '\f00c';
}
}
}
}
.dropdown-title {
position: relative;
padding: 2px 25px 10px;
......@@ -724,7 +727,6 @@
}
}
.dropdown-menu-due-date {
.dropdown-content {
max-height: 230px;
......@@ -854,9 +856,13 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
.projects-list-frequent-container,
.projects-list-search-container, {
.projects-list-search-container {
padding: 8px 0;
overflow-y: auto;
li.section-empty.section-failure {
color: $callout-danger-color;
}
}
.section-header,
......@@ -867,13 +873,6 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
font-size: $gl-font-size;
}
.projects-list-frequent-container,
.projects-list-search-container {
li.section-empty.section-failure {
color: $callout-danger-color;
}
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
......@@ -905,8 +904,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
.projects-list-item-container {
.project-item-avatar-container
.project-item-metadata-container {
.project-item-avatar-container .project-item-metadata-container {
float: left;
}
......
......@@ -17,6 +17,7 @@
}
.ide-view {
position: relative;
display: flex;
height: calc(100vh - #{$header-height});
margin-top: 0;
......@@ -876,6 +877,26 @@
font-weight: $gl-font-weight-bold;
}
.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;
......
---
title: Added fuzzy file finder to web IDE
merge_request:
author:
type: added
import Vue from 'vue';
import store from '~/ide/stores';
import FindFileComponent from '~/ide/components/file_finder/index.vue';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import router from '~/ide/ide_router';
import { file, resetStore } from '../../helpers';
import { mountComponentWithStore } from '../../../helpers/vue_mount_component_helper';
describe('IDE File finder item spec', () => {
const Component = Vue.extend(FindFileComponent);
let vm;
beforeEach(done => {
setFixtures('<div id="app"></div>');
vm = mountComponentWithStore(Component, {
store,
el: '#app',
props: {
index: 0,
},
});
setTimeout(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
describe('with entries', () => {
beforeEach(done => {
Vue.set(vm.$store.state.entries, 'folder', {
...file('folder'),
path: 'folder',
type: 'folder',
});
Vue.set(vm.$store.state.entries, 'index.js', {
...file('index.js'),
path: 'index.js',
type: 'blob',
url: '/index.jsurl',
});
Vue.set(vm.$store.state.entries, 'component.js', {
...file('component.js'),
path: 'component.js',
type: 'blob',
});
setTimeout(done);
});
it('renders list of blobs', () => {
expect(vm.$el.textContent).toContain('index.js');
expect(vm.$el.textContent).toContain('component.js');
expect(vm.$el.textContent).not.toContain('folder');
});
it('filters entries', done => {
vm.searchText = 'index';
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain('index.js');
expect(vm.$el.textContent).not.toContain('component.js');
done();
});
});
it('shows clear button when searchText is not empty', done => {
vm.searchText = 'index';
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dropdown-input-clear').classList).toContain('show');
expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden');
done();
});
});
it('clear button resets searchText', done => {
vm.searchText = 'index';
vm
.$nextTick()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
.then(vm.$nextTick)
.then(() => {
expect(vm.searchText).toBe('');
})
.then(done)
.catch(done.fail);
});
it('clear button focues search input', done => {
spyOn(vm.$refs.searchInput, 'focus');
vm.searchText = 'index';
vm
.$nextTick()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
.then(vm.$nextTick)
.then(() => {
expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
describe('listShowCount', () => {
it('returns 1 when no filtered entries exist', done => {
vm.searchText = 'testing 123';
vm.$nextTick(() => {
expect(vm.listShowCount).toBe(1);
done();
});
});
it('returns entries length when not filtered', () => {
expect(vm.listShowCount).toBe(2);
});
});
describe('listHeight', () => {
it('returns 55 when entries exist', () => {
expect(vm.listHeight).toBe(55);
});
it('returns 33 when entries dont exist', done => {
vm.searchText = 'testing 123';
vm.$nextTick(() => {
expect(vm.listHeight).toBe(33);
done();
});
});
});
describe('filteredBlobsLength', () => {
it('returns length of filtered blobs', done => {
vm.searchText = 'index';
vm.$nextTick(() => {
expect(vm.filteredBlobsLength).toBe(1);
done();
});
});
});
describe('watches', () => {
describe('searchText', () => {
it('resets focusedIndex when updated', done => {
vm.focusedIndex = 1;
vm.searchText = 'test';
vm.$nextTick(() => {
expect(vm.focusedIndex).toBe(0);
done();
});
});
});
describe('fileFindVisible', () => {
it('returns searchText when false', done => {
vm.searchText = 'test';
vm.$store.state.fileFindVisible = true;
vm
.$nextTick()
.then(() => {
vm.$store.state.fileFindVisible = false;
})
.then(vm.$nextTick)
.then(() => {
expect(vm.searchText).toBe('');
})
.then(done)
.catch(done.fail);
});
});
});
describe('openFile', () => {
beforeEach(() => {
spyOn(router, 'push');
spyOn(vm, 'toggleFileFinder');
});
it('closes file finder', () => {
vm.openFile(vm.$store.state.entries['index.js']);
expect(vm.toggleFileFinder).toHaveBeenCalled();
});
it('pushes to router', () => {
vm.openFile(vm.$store.state.entries['index.js']);
expect(router.push).toHaveBeenCalledWith('/project/index.jsurl');
});
});
describe('onKeyup', () => {
it('opens file on enter key', done => {
const event = new CustomEvent('keyup');
event.keyCode = ENTER_KEY_CODE;
spyOn(vm, 'openFile');
vm.$refs.searchInput.dispatchEvent(event);
vm.$nextTick(() => {
expect(vm.openFile).toHaveBeenCalledWith(vm.$store.state.entries['index.js']);
done();
});
});
it('closes file finder on esc key', done => {
const event = new CustomEvent('keyup');
event.keyCode = ESC_KEY_CODE;
spyOn(vm, 'toggleFileFinder');
vm.$refs.searchInput.dispatchEvent(event);
vm.$nextTick(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
done();
});
});
});
describe('onKeyDown', () => {
let el;
beforeEach(() => {
el = vm.$refs.searchInput;
});
describe('up key', () => {
const event = new CustomEvent('keydown');
event.keyCode = UP_KEY_CODE;
it('resets to last index when at top', () => {
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(1);
});
it('minus 1 from focusedIndex', () => {
vm.focusedIndex = 1;
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(0);
});
});
describe('down key', () => {
const event = new CustomEvent('keydown');
event.keyCode = DOWN_KEY_CODE;
it('resets to first index when at bottom', () => {
vm.focusedIndex = 1;
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(0);
});
it('adds 1 to focusedIndex', () => {
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(1);
});
});
});
});
describe('without entries', () => {
it('renders loading text when loading', done => {
store.state.loading = true;
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain('Loading...');
done();
});
});
it('renders no files text', () => {
expect(vm.$el.textContent).toContain('No files found.');
});
});
});
import Vue from 'vue';
import ItemComponent from '~/ide/components/file_finder/item.vue';
import { file } from '../../helpers';
import createComponent from '../../../helpers/vue_mount_component_helper';
describe('IDE File finder item spec', () => {
const Component = Vue.extend(ItemComponent);
let vm;
let localFile;
beforeEach(() => {
localFile = {
...file(),
name: 'test file',
path: 'test/file',
};
vm = createComponent(Component, {
file: localFile,
focused: true,
searchText: '',
index: 0,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders file name & path', () => {
expect(vm.$el.textContent).toContain('test file');
expect(vm.$el.textContent).toContain('test/file');
});
describe('focused', () => {
it('adds is-focused class', () => {
expect(vm.$el.classList).toContain('is-focused');
});
it('does not have is-focused class when not focused', done => {
vm.focused = false;
vm.$nextTick(() => {
expect(vm.$el.classList).not.toContain('is-focused');
done();
});
});
});
describe('changed file icon', () => {
it('does not render when not a changed or temp file', () => {
expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null);
});
it('renders when a changed file', done => {
vm.file.changed = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
done();
});
});
it('renders when a temp file', done => {
vm.file.tempFile = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
done();
});
});
});
it('emits event when clicked', () => {
spyOn(vm, '$emit');
vm.$el.click();
expect(vm.$emit).toHaveBeenCalledWith('click', vm.file);
});
describe('path', () => {
let el;
beforeEach(done => {
vm.searchText = 'file';
el = vm.$el.querySelector('.diff-changed-file-path');
vm.$nextTick(done);
});
it('highlights text', () => {
expect(el.querySelectorAll('.highlighted').length).toBe(4);
});
it('adds ellipsis to long text', done => {
vm.file.path = new Array(70)
.fill()
.map((_, i) => `${i}-`)
.join('');
vm.$nextTick(() => {
expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`);
done();
});
});
});
describe('name', () => {
let el;
beforeEach(done => {
vm.searchText = 'file';
el = vm.$el.querySelector('.diff-changed-file-name');
vm.$nextTick(done);
});
it('highlights text', () => {
expect(el.querySelectorAll('.highlighted').length).toBe(4);
});
it('does not add ellipsis to long text', done => {
vm.file.name = new Array(70)
.fill()
.map((_, i) => `${i}-`)
.join('');
vm.$nextTick(() => {
expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`);
done();
});
});
});
});
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';
......@@ -38,4 +39,68 @@ describe('ide component', () => {
done();
});
});
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);
});
});
});
......@@ -340,4 +340,17 @@ describe('Multi-file store actions', () => {
.catch(done.fail);
});
});
describe('toggleFileFinder', () => {
it('commits TOGGLE_FILE_FINDER', done => {
testAction(
actions.toggleFileFinder,
true,
null,
[{ type: 'TOGGLE_FILE_FINDER', payload: true }],
[],
done,
);
});
});
});
......@@ -64,4 +64,24 @@ describe('IDE store getters', () => {
expect(getters.currentMergeRequest(localState)).toBeNull();
});
});
describe('allBlobs', () => {
beforeEach(() => {
Object.assign(localState.entries, {
index: { type: 'blob', name: 'index', lastOpenedAt: 0 },
app: { type: 'blob', name: 'blob', lastOpenedAt: 0 },
folder: { type: 'folder', name: 'folder', lastOpenedAt: 0 },
});
});
it('returns only blobs', () => {
expect(getters.allBlobs(localState).length).toBe(2);
});
it('returns list sorted by lastOpenedAt', () => {
localState.entries.app.lastOpenedAt = new Date().getTime();
expect(getters.allBlobs(localState)[0].name).toBe('blob');
});
});
});
......@@ -86,4 +86,12 @@ describe('Multi-file store mutations', () => {
expect(localState.viewer).toBe('diff');
});
});
describe('TOGGLE_FILE_FINDER', () => {
it('updates fileFindVisible', () => {
mutations.TOGGLE_FILE_FINDER(localState, true);
expect(localState.fileFindVisible).toBe(true);
});
});
});
......@@ -8474,6 +8474,10 @@ vue-template-es2015-compiler@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz#dc42697133302ce3017524356a6c61b7b69b4a18"
vue-virtual-scroll-list@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.2.5.tgz#bcbd010f7cdb035eba8958ebf807c6214d9a167a"
vue@^2.5.13:
version "2.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.13.tgz#95bd31e20efcf7a7f39239c9aa6787ce8cf578e1"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment