Commit bb8e4268 authored by Filipa Lacerda's avatar Filipa Lacerda
Browse files

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
Loading
Loading
Loading
Loading
Loading
+245 −0
Original line number Original line Diff line number Diff line
<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>
+113 −0
Original line number Original line Diff line number Diff line
<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>
+75 −39
Original line number Original line Diff line number Diff line
<script>
<script>
import { mapState, mapGetters } from 'vuex';
  import { mapActions, mapState, mapGetters } from 'vuex';
  import Mousetrap from 'mousetrap';
  import ideSidebar from './ide_side_bar.vue';
  import ideSidebar from './ide_side_bar.vue';
  import ideContextbar from './ide_context_bar.vue';
  import ideContextbar from './ide_context_bar.vue';
  import repoTabs from './repo_tabs.vue';
  import repoTabs from './repo_tabs.vue';
  import ideStatusBar from './ide_status_bar.vue';
  import ideStatusBar from './ide_status_bar.vue';
  import repoEditor from './repo_editor.vue';
  import repoEditor from './repo_editor.vue';
  import FindFile from './file_finder/index.vue';

  const originalStopCallback = Mousetrap.stopCallback;


  export default {
  export default {
    components: {
    components: {
@@ -13,6 +17,7 @@ export default {
      repoTabs,
      repoTabs,
      ideStatusBar,
      ideStatusBar,
      repoEditor,
      repoEditor,
      FindFile,
    },
    },
    props: {
    props: {
      emptyStateSvgPath: {
      emptyStateSvgPath: {
@@ -29,7 +34,13 @@ export default {
      },
      },
    },
    },
    computed: {
    computed: {
    ...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']),
      ...mapState([
        'changedFiles',
        'openFiles',
        'viewer',
        'currentMergeRequestId',
        'fileFindVisible',
      ]),
      ...mapGetters(['activeFile', 'hasChanges']),
      ...mapGetters(['activeFile', 'hasChanges']),
    },
    },
    mounted() {
    mounted() {
@@ -42,6 +53,28 @@ export default {
        });
        });
        return returnValue;
        return returnValue;
      };
      };

      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']),
      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>
</script>
@@ -50,6 +83,9 @@ export default {
  <div
  <div
    class="ide-view"
    class="ide-view"
  >
  >
    <find-file
      v-show="fileFindVisible"
    />
    <ide-sidebar />
    <ide-sidebar />
    <div
    <div
      class="multi-file-edit-pane"
      class="multi-file-edit-pane"
+5 −0
Original line number Original line Diff line number Diff line
// Fuzzy file finder
// 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_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
export const MAX_BODY_LENGTH = 72;
+33 −0
Original line number Original line Diff line number Diff line
import _ from 'underscore';
import _ from 'underscore';
import store from '../stores';
import DecorationsController from './decorations/controller';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import editorOptions, { defaultEditorOptions } from './editor_options';
import gitlabTheme from './themes/gl_theme';
import gitlabTheme from './themes/gl_theme';
import keymap from './keymap.json';


export const clearDomElement = el => {
export const clearDomElement = el => {
  if (!el || !el.firstChild) return;
  if (!el || !el.firstChild) return;
@@ -53,6 +55,8 @@ export default class Editor {
        )),
        )),
      );
      );


      this.addCommands();

      window.addEventListener('resize', this.debouncedUpdate, false);
      window.addEventListener('resize', this.debouncedUpdate, false);
    }
    }
  }
  }
@@ -73,6 +77,8 @@ export default class Editor {
        })),
        })),
      );
      );


      this.addCommands();

      window.addEventListener('resize', this.debouncedUpdate, false);
      window.addEventListener('resize', this.debouncedUpdate, false);
    }
    }
  }
  }
@@ -189,4 +195,31 @@ export default class Editor {
  static renderSideBySide(domElement) {
  static renderSideBySide(domElement) {
    return domElement.offsetWidth >= 700;
    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;
        },
      });
    });
  }
}
}
Loading